initial commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.previewWrapper {
|
||||
background: var(--background-secondary-lighter);
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.previewActionsRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.previewAvatarsRow {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.previewMessageContainer {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
164
fluxer_app/src/components/modals/tabs/AccessibilityTab.tsx
Normal file
164
fluxer_app/src/components/modals/tabs/AccessibilityTab.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {MessagePreviewContext, MessageStates, MessageTypes, StatusTypes} from '~/Constants';
|
||||
import {Message} from '~/components/channel/Message';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import {MessageRecord} from '~/records/MessageRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {AnimationTabContent} from './AccessibilityTab/AnimationTab';
|
||||
import {KeyboardTabContent} from './AccessibilityTab/KeyboardTab';
|
||||
import {MotionTabContent} from './AccessibilityTab/MotionTab';
|
||||
import {VisualTabContent} from './AccessibilityTab/VisualTab';
|
||||
import styles from './AccessibilityTab.module.css';
|
||||
|
||||
export const AccessibilityTabPreview = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
|
||||
|
||||
const fakeData = React.useMemo(() => {
|
||||
const tabOpenedAt = new Date();
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const author = currentUser?.toJSON() || {
|
||||
id: 'preview-user',
|
||||
username: 'PreviewUser',
|
||||
discriminator: '0000',
|
||||
global_name: 'Preview User',
|
||||
avatar: null,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const fakeChannel = new ChannelRecord({
|
||||
id: 'fake-accessibility-channel',
|
||||
type: 0,
|
||||
name: 'accessibility-preview',
|
||||
position: 0,
|
||||
parent_id: null,
|
||||
topic: null,
|
||||
url: null,
|
||||
nsfw: false,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
|
||||
const fakeMessage = new MessageRecord(
|
||||
{
|
||||
id: 'accessibility-preview-1',
|
||||
channel_id: 'fake-accessibility-channel',
|
||||
author,
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
content: t`This shows how links appear: https://fluxer.app`,
|
||||
timestamp: tabOpenedAt.toISOString(),
|
||||
state: MessageStates.SENT,
|
||||
},
|
||||
{skipUserCache: true},
|
||||
);
|
||||
|
||||
return {fakeChannel, fakeMessage};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.previewWrapper}>
|
||||
<div className={styles.previewContainer}>
|
||||
<div className={styles.previewActionsRow}>
|
||||
<Button small={true} onClick={() => {}}>
|
||||
{t`Preview Button`}
|
||||
</Button>
|
||||
<div className={styles.previewAvatarsRow}>
|
||||
<MockAvatar size={32} status={StatusTypes.ONLINE} />
|
||||
<MockAvatar size={32} status={StatusTypes.DND} />
|
||||
<MockAvatar size={32} status={StatusTypes.IDLE} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.previewMessageContainer}>
|
||||
<Message
|
||||
channel={fakeData.fakeChannel}
|
||||
message={fakeData.fakeMessage}
|
||||
previewContext={MessagePreviewContext.SETTINGS}
|
||||
previewOverrides={{
|
||||
usernameColor: '#e91e63',
|
||||
...(alwaysUnderlineLinks
|
||||
? {
|
||||
linkStyle: 'always-underline',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const AccessibilityTabComponent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection
|
||||
id="visual"
|
||||
title={t`Visual`}
|
||||
description={t`Customize visual elements to improve visibility and readability.`}
|
||||
>
|
||||
<VisualTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="keyboard" title={t`Keyboard`} description={t`Customize keyboard navigation behavior.`}>
|
||||
<KeyboardTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="animation"
|
||||
title={t`Animation`}
|
||||
description={t`Control animated content throughout the app.`}
|
||||
>
|
||||
<AnimationTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="motion"
|
||||
title={t`Motion`}
|
||||
description={t`Control animations and transitions throughout the app.`}
|
||||
isAdvanced
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<MotionTabContent />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default AccessibilityTabComponent;
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.radioSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.radioHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.radioDescription {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {StickerAnimationOptions} from '~/Constants';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import AccessibilityOverrideStore, {type AnimationOverrides} from '~/stores/AccessibilityOverrideStore';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import styles from './AnimationTab.module.css';
|
||||
|
||||
export const AnimationTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
const mobileStickerAnimationOverridden = AccessibilityStore.mobileStickerAnimationOverridden;
|
||||
const mobileGifAutoPlayOverridden = AccessibilityStore.mobileGifAutoPlayOverridden;
|
||||
|
||||
const stickerAnimationOptions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{value: StickerAnimationOptions.ALWAYS_ANIMATE, name: t`Always animate`, desc: t`Stickers will always animate`},
|
||||
{
|
||||
value: StickerAnimationOptions.ANIMATE_ON_INTERACTION,
|
||||
name: t`Animate on interaction`,
|
||||
desc: mobileLayout.enabled
|
||||
? t`Stickers will animate when you press them`
|
||||
: t`Stickers will animate when you hover or interact with them`,
|
||||
},
|
||||
{value: StickerAnimationOptions.NEVER_ANIMATE, name: t`Never animate`, desc: t`Stickers will never animate`},
|
||||
] as ReadonlyArray<RadioOption<number>>,
|
||||
[mobileLayout.enabled, t],
|
||||
);
|
||||
|
||||
const getOverrideDescription = (
|
||||
setting: 'gif_auto_play' | 'animate_emoji' | 'animate_stickers',
|
||||
): string | undefined => {
|
||||
if (AccessibilityOverrideStore.isOverriddenByReducedMotion(setting)) {
|
||||
return t`This setting is currently overridden by your reduced motion preferences`;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const handleAnimationSettingChange = (
|
||||
setting: 'gif_auto_play' | 'animate_emoji' | 'animate_stickers',
|
||||
_value: unknown,
|
||||
updateAction: () => void,
|
||||
) => {
|
||||
const dirtyKey: keyof AnimationOverrides =
|
||||
setting === 'gif_auto_play'
|
||||
? 'gifAutoPlayDirty'
|
||||
: setting === 'animate_emoji'
|
||||
? 'animateEmojiDirty'
|
||||
: 'animateStickersDirty';
|
||||
AccessibilityOverrideStore.markDirty(dirtyKey);
|
||||
updateAction();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
label={t`Play animated emojis`}
|
||||
description={getOverrideDescription('animate_emoji')}
|
||||
value={UserSettingsStore.getAnimateEmoji()}
|
||||
onChange={(value) => {
|
||||
if (mobileLayout.enabled) {
|
||||
AccessibilityActionCreators.update({
|
||||
mobileAnimateEmojiOverridden: true,
|
||||
mobileAnimateEmojiValue: value,
|
||||
});
|
||||
} else {
|
||||
handleAnimationSettingChange('animate_emoji', value, () =>
|
||||
UserSettingsActionCreators.update({animateEmoji: value}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={mobileLayout.enabled ? t`Automatically play GIFs` : t`Automatically play GIFs when Fluxer is focused`}
|
||||
description={
|
||||
mobileLayout.enabled && !mobileGifAutoPlayOverridden
|
||||
? t`Defaults to off on mobile to preserve battery life and data usage.`
|
||||
: getOverrideDescription('gif_auto_play')
|
||||
}
|
||||
value={UserSettingsStore.getGifAutoPlay()}
|
||||
onChange={(value) => {
|
||||
if (mobileLayout.enabled) {
|
||||
AccessibilityActionCreators.update({
|
||||
mobileGifAutoPlayOverridden: true,
|
||||
mobileGifAutoPlayValue: value,
|
||||
});
|
||||
} else {
|
||||
handleAnimationSettingChange('gif_auto_play', value, () =>
|
||||
UserSettingsActionCreators.update({gifAutoPlay: value}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.radioSection}>
|
||||
<div className={styles.radioHeader}>
|
||||
<div className={styles.radioLabel}>
|
||||
<Trans>Sticker animations</Trans>
|
||||
</div>
|
||||
{mobileLayout.enabled && !mobileStickerAnimationOverridden ? (
|
||||
<p className={styles.radioDescription}>
|
||||
{t`Defaults to animate on interaction on mobile to preserve battery life.`}
|
||||
</p>
|
||||
) : (
|
||||
getOverrideDescription('animate_stickers') && (
|
||||
<p className={styles.radioDescription}>{getOverrideDescription('animate_stickers')}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
aria-label={t`Sticker animation preference`}
|
||||
options={stickerAnimationOptions}
|
||||
value={UserSettingsStore.getAnimateStickers()}
|
||||
onChange={(value) => {
|
||||
if (mobileLayout.enabled) {
|
||||
AccessibilityActionCreators.update({
|
||||
mobileStickerAnimationOverridden: true,
|
||||
mobileStickerAnimationValue: value,
|
||||
});
|
||||
} else {
|
||||
handleAnimationSettingChange('animate_stickers', value, () =>
|
||||
UserSettingsActionCreators.update({animateStickers: value}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {AnimationTabContent} from './AnimationTab';
|
||||
import styles from './Inline.module.css';
|
||||
import {MotionTabContent} from './MotionTab';
|
||||
import {VisualTabContent} from './VisualTab';
|
||||
|
||||
export const AccessibilityInlineTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SettingsSection id="accessibility-visual" title={t`Visual`}>
|
||||
<VisualTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="accessibility-animation" title={t`Animation`}>
|
||||
<AnimationTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="accessibility-motion" title={t`Motion`}>
|
||||
<MotionTabContent />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
|
||||
export const KeyboardTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const showTextareaFocusRing = AccessibilityStore.showTextareaFocusRing;
|
||||
const escapeExitsKeyboardMode = AccessibilityStore.escapeExitsKeyboardMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
label={t`Show focus ring on chat textarea`}
|
||||
description={t`Display a visible focus indicator around the message input when focused. Disable for a more subtle appearance.`}
|
||||
value={showTextareaFocusRing}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showTextareaFocusRing: value})}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t`Escape key exits keyboard mode`}
|
||||
description={t`Allow pressing Escape to exit keyboard navigation mode. Note: This may conflict with other uses of Escape.`}
|
||||
value={escapeExitsKeyboardMode}
|
||||
onChange={(value) => AccessibilityActionCreators.update({escapeExitsKeyboardMode: value})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
|
||||
export const MotionTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const syncReducedMotionWithSystem = AccessibilityStore.syncReducedMotionWithSystem;
|
||||
const reducedMotionOverride = AccessibilityStore.reducedMotionOverride;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
label={t`Sync reduced motion setting with system`}
|
||||
description={t`Automatically use your system's reduced motion preference, or customize it below.`}
|
||||
value={syncReducedMotionWithSystem}
|
||||
onChange={(value) => AccessibilityActionCreators.update({syncReducedMotionWithSystem: value})}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t`Reduce motion`}
|
||||
description={
|
||||
syncReducedMotionWithSystem
|
||||
? t`Disable animations and transitions. Currently controlled by your system setting.`
|
||||
: t`Disable animations and transitions throughout the app.`
|
||||
}
|
||||
value={syncReducedMotionWithSystem ? AccessibilityStore.useReducedMotion : (reducedMotionOverride ?? false)}
|
||||
disabled={syncReducedMotionWithSystem}
|
||||
onChange={(value) => AccessibilityActionCreators.update({reducedMotionOverride: value})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sliderSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sliderHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sliderLabel {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sliderDescription {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import AccessibilityStore, {DMMessagePreviewMode} from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import styles from './VisualTab.module.css';
|
||||
|
||||
const dmMessagePreviewOptions = (
|
||||
t: (msg: MessageDescriptor) => string,
|
||||
): ReadonlyArray<RadioOption<DMMessagePreviewMode>> => [
|
||||
{
|
||||
value: DMMessagePreviewMode.ALL,
|
||||
name: t(msg`All messages`),
|
||||
desc: t(msg`Show message previews for all DM conversations`),
|
||||
},
|
||||
{
|
||||
value: DMMessagePreviewMode.UNREAD_ONLY,
|
||||
name: t(msg`Unread DMs only`),
|
||||
desc: t(msg`Only show message previews for DMs with unread messages`),
|
||||
},
|
||||
{
|
||||
value: DMMessagePreviewMode.NONE,
|
||||
name: t(msg`None`),
|
||||
desc: t(msg`Don't show message previews in the DM list`),
|
||||
},
|
||||
];
|
||||
|
||||
export const VisualTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const saturationFactor = AccessibilityStore.saturationFactor;
|
||||
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
|
||||
const enableTextSelection = AccessibilityStore.enableTextSelection;
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sliderSection}>
|
||||
<div className={styles.sliderHeader}>
|
||||
<label htmlFor="saturation" className={styles.sliderLabel}>
|
||||
<Trans>Saturation</Trans>
|
||||
</label>
|
||||
<p className={styles.sliderDescription}>
|
||||
<Trans>Adjust the saturation of all theme colors.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={saturationFactor * 100}
|
||||
factoryDefaultValue={100}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
step={1}
|
||||
markers={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
|
||||
stickToMarkers={false}
|
||||
onMarkerRender={(value) => `${value}%`}
|
||||
onValueRender={(value) => <Trans>{value}%</Trans>}
|
||||
onValueChange={(value) => AccessibilityActionCreators.update({saturationFactor: value / 100})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
label={t(msg`Always underline links`)}
|
||||
description={t(msg`Make links to websites stand out more by always underlining them.`)}
|
||||
value={alwaysUnderlineLinks}
|
||||
onChange={(value) => AccessibilityActionCreators.update({alwaysUnderlineLinks: value})}
|
||||
/>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Interaction</Trans>}
|
||||
description={<Trans>Adjust how you interact with the app</Trans>}
|
||||
>
|
||||
<Switch
|
||||
label={t(msg`Enable text selection`)}
|
||||
description={
|
||||
mobileLayout.enabled
|
||||
? t(
|
||||
msg`Allow selecting all text content in the app. This setting is disabled on mobile to prevent interference with touch interactions.`,
|
||||
)
|
||||
: t(msg`Allow selecting all text content in the app.`)
|
||||
}
|
||||
value={enableTextSelection}
|
||||
disabled={mobileLayout.enabled}
|
||||
onChange={(value) => AccessibilityActionCreators.update({enableTextSelection: value})}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>DM Message Previews</Trans>}
|
||||
description={<Trans>Control when message previews are shown in the DM list</Trans>}
|
||||
>
|
||||
<RadioGroup
|
||||
options={dmMessagePreviewOptions(t)}
|
||||
value={AccessibilityStore.dmMessagePreviewMode}
|
||||
onChange={(value) => AccessibilityActionCreators.update({dmMessagePreviewMode: value})}
|
||||
aria-label={t(msg`DM message preview mode`)}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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: var(--spacing-6);
|
||||
}
|
||||
114
fluxer_app/src/components/modals/tabs/AccountSecurityTab.tsx
Normal file
114
fluxer_app/src/components/modals/tabs/AccountSecurityTab.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {UserAuthenticatorTypes, UserFlags} from '~/Constants';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {AccountTabContent} from './AccountSecurityTab/AccountTab';
|
||||
import {DangerZoneTabContent} from './AccountSecurityTab/DangerZoneTab';
|
||||
import {SecurityTabContent} from './AccountSecurityTab/SecurityTab';
|
||||
|
||||
const AccountSecurityTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const user = UserStore.currentUser;
|
||||
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
||||
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
||||
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
||||
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
||||
const [disablingSmsMfa, setDisablingSmsMfa] = useState(false);
|
||||
|
||||
const loadPasskeys = useCallback(async () => {
|
||||
setLoadingPasskeys(true);
|
||||
try {
|
||||
const credentials = await UserActionCreators.listWebAuthnCredentials();
|
||||
setPasskeys(credentials);
|
||||
} catch (error) {
|
||||
console.error('Failed to load passkeys', error);
|
||||
} finally {
|
||||
setLoadingPasskeys(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPasskeys();
|
||||
}, [loadPasskeys]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const hasSmsMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.SMS) ?? false;
|
||||
const hasTotpMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.TOTP) ?? false;
|
||||
const isSmsMfaDisabledForUser =
|
||||
(user.flags & UserFlags.STAFF) !== 0 ||
|
||||
(user.flags & UserFlags.CTP_MEMBER) !== 0 ||
|
||||
(user.flags & UserFlags.PARTNER) !== 0;
|
||||
|
||||
const isClaimed = user.isClaimed();
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection
|
||||
id="account"
|
||||
title={t`Account`}
|
||||
description={t`Manage your email, password, and account settings`}
|
||||
>
|
||||
<AccountTabContent
|
||||
user={user}
|
||||
isClaimed={isClaimed}
|
||||
showMaskedEmail={showMaskedEmail}
|
||||
setShowMaskedEmail={setShowMaskedEmail}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="security"
|
||||
title={t`Security`}
|
||||
description={t`Protect your account with two-factor authentication and passkeys`}
|
||||
>
|
||||
<SecurityTabContent
|
||||
user={user}
|
||||
isClaimed={isClaimed}
|
||||
hasSmsMfa={hasSmsMfa}
|
||||
hasTotpMfa={hasTotpMfa}
|
||||
isSmsMfaDisabledForUser={isSmsMfaDisabledForUser}
|
||||
passkeys={passkeys}
|
||||
loadingPasskeys={loadingPasskeys}
|
||||
enablingSmsMfa={enablingSmsMfa}
|
||||
disablingSmsMfa={disablingSmsMfa}
|
||||
loadPasskeys={loadPasskeys}
|
||||
setEnablingSmsMfa={setEnablingSmsMfa}
|
||||
setDisablingSmsMfa={setDisablingSmsMfa}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="danger_zone" title={t`Danger Zone`} description={t`Irreversible and destructive actions`}>
|
||||
<DangerZoneTabContent user={user} isClaimed={isClaimed} />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default AccountSecurityTab;
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rowContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.emailRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.emailRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.emailText {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.emailTextSelectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
margin-top: 0.1em;
|
||||
text-align: left;
|
||||
color: var(--text-link);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.toggleButton {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.warningText {
|
||||
color: var(--alert-warning-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
|
||||
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import * as DateUtils from '~/utils/DateUtils';
|
||||
import {EmailVerificationAlert} from '../../components/EmailVerificationAlert';
|
||||
import {UnclaimedAccountAlert} from '../../components/UnclaimedAccountAlert';
|
||||
import styles from './AccountTab.module.css';
|
||||
|
||||
const maskEmail = (email: string): string => {
|
||||
const [username, domain] = email.split('@');
|
||||
const maskedUsername = username.replace(/./g, '*');
|
||||
return `${maskedUsername}@${domain}`;
|
||||
};
|
||||
|
||||
interface AccountTabProps {
|
||||
user: UserRecord;
|
||||
isClaimed: boolean;
|
||||
showMaskedEmail: boolean;
|
||||
setShowMaskedEmail: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const AccountTabContent: React.FC<AccountTabProps> = observer(
|
||||
({user, isClaimed, showMaskedEmail, setShowMaskedEmail}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
return (
|
||||
<>
|
||||
{!isClaimed && <UnclaimedAccountAlert />}
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Email Settings</Trans>}
|
||||
description={<Trans>Manage the email address you use to sign in to Fluxer</Trans>}
|
||||
>
|
||||
{isClaimed ? (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Email Address</Trans>
|
||||
</div>
|
||||
<div className={styles.emailRow}>
|
||||
<span className={`${styles.emailText} ${showMaskedEmail ? styles.emailTextSelectable : ''}`}>
|
||||
{showMaskedEmail ? user.email : maskEmail(user.email!)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setShowMaskedEmail(!showMaskedEmail)}
|
||||
>
|
||||
{showMaskedEmail ? t`Hide` : t`Reveal`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <EmailChangeModal />))}>
|
||||
<Trans>Change Email</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{user.email && !user.verified && <EmailVerificationAlert />}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Email Address</Trans>
|
||||
</div>
|
||||
<div className={styles.warningText}>
|
||||
<Trans>No email address set</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Trans>Add Email</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Password</Trans>}
|
||||
description={<Trans>Change your password to keep your account secure</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
{isClaimed ? (
|
||||
<>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Current Password</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{user.passwordLastChangedAt ? (
|
||||
<Trans>Last changed: {DateUtils.getRelativeDateString(user.passwordLastChangedAt, i18n)}</Trans>
|
||||
) : (
|
||||
<Trans>Last changed: Never</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <PasswordChangeModal />))}>
|
||||
<Trans>Change Password</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Password</Trans>
|
||||
</div>
|
||||
<div className={styles.warningText}>
|
||||
<Trans>No password set</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {AccountDeleteModal} from '~/components/modals/AccountDeleteModal';
|
||||
import {AccountDisableModal} from '~/components/modals/AccountDisableModal';
|
||||
import {GuildOwnershipWarningModal} from '~/components/modals/GuildOwnershipWarningModal';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import styles from './AccountTab.module.css';
|
||||
|
||||
interface DangerZoneTabProps {
|
||||
user: UserRecord;
|
||||
isClaimed: boolean;
|
||||
}
|
||||
|
||||
export const DangerZoneTabContent: React.FC<DangerZoneTabProps> = observer(({user, isClaimed}) => {
|
||||
const handleDisableAccount = () => {
|
||||
const ownedGuilds = GuildStore.getOwnedGuilds(user.id);
|
||||
if (ownedGuilds.length > 0) {
|
||||
ModalActionCreators.push(modal(() => <GuildOwnershipWarningModal ownedGuilds={ownedGuilds} action="disable" />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <AccountDisableModal />));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
const ownedGuilds = GuildStore.getOwnedGuilds(user.id);
|
||||
if (ownedGuilds.length > 0) {
|
||||
ModalActionCreators.push(modal(() => <GuildOwnershipWarningModal ownedGuilds={ownedGuilds} action="delete" />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <AccountDeleteModal />));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isClaimed && (
|
||||
<SettingsTabSection
|
||||
title={<Trans>Disable Account</Trans>}
|
||||
description={<Trans>Temporarily disable your account. You can reactivate it later.</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Disable Account</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Trans>Temporarily disable your account. You can reactivate it later by signing back in.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="danger-secondary" small={true} onClick={handleDisableAccount}>
|
||||
<Trans>Disable Account</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Delete Account</Trans>}
|
||||
description={<Trans>Permanently delete your account and all associated data. This cannot be undone.</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Delete Account</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Trans>Permanently delete your account and all associated data. This action cannot be undone.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="danger-primary" small={true} onClick={handleDeleteAccount}>
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {UserAuthenticatorTypes, UserFlags} from '~/Constants';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {AccountTabContent as AccountTab} from './AccountTab';
|
||||
import {DangerZoneTabContent as DangerZoneTab} from './DangerZoneTab';
|
||||
import styles from './Inline.module.css';
|
||||
import {SecurityTabContent as SecurityTab} from './SecurityTab';
|
||||
|
||||
export const AccountSecurityInlineTab = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const user = UserStore.currentUser;
|
||||
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
||||
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
||||
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
||||
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
||||
const [disablingSmsMfa, setDisablingSmsMfa] = useState(false);
|
||||
|
||||
const loadPasskeys = useCallback(async () => {
|
||||
setLoadingPasskeys(true);
|
||||
try {
|
||||
const credentials = await UserActionCreators.listWebAuthnCredentials();
|
||||
setPasskeys(credentials);
|
||||
} catch (error) {
|
||||
console.error('Failed to load passkeys', error);
|
||||
} finally {
|
||||
setLoadingPasskeys(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPasskeys();
|
||||
}, [loadPasskeys]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const hasSmsMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.SMS) ?? false;
|
||||
const hasTotpMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.TOTP) ?? false;
|
||||
const isSmsMfaDisabledForUser =
|
||||
(user.flags & UserFlags.STAFF) !== 0 ||
|
||||
(user.flags & UserFlags.CTP_MEMBER) !== 0 ||
|
||||
(user.flags & UserFlags.PARTNER) !== 0;
|
||||
|
||||
const isClaimed = user.isClaimed();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SettingsSection id="account" title={t`Account`}>
|
||||
<AccountTab
|
||||
user={user}
|
||||
isClaimed={isClaimed}
|
||||
showMaskedEmail={showMaskedEmail}
|
||||
setShowMaskedEmail={setShowMaskedEmail}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection id="security" title={t`Security`}>
|
||||
<SecurityTab
|
||||
user={user}
|
||||
isClaimed={isClaimed}
|
||||
hasSmsMfa={hasSmsMfa}
|
||||
hasTotpMfa={hasTotpMfa}
|
||||
isSmsMfaDisabledForUser={isSmsMfaDisabledForUser}
|
||||
passkeys={passkeys}
|
||||
loadingPasskeys={loadingPasskeys}
|
||||
enablingSmsMfa={enablingSmsMfa}
|
||||
disablingSmsMfa={disablingSmsMfa}
|
||||
loadPasskeys={loadPasskeys}
|
||||
setEnablingSmsMfa={setEnablingSmsMfa}
|
||||
setDisablingSmsMfa={setDisablingSmsMfa}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection id="danger_zone" title={t`Danger Zone`}>
|
||||
<DangerZoneTab user={user} isClaimed={isClaimed} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rowContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.passkeyList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.passkeyItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.passkeyItem {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.passkeyInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.passkeyName {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.passkeyDetails {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.passkeyActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/*
|
||||
* 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 {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
|
||||
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
|
||||
import {PasskeyNameModal} from '~/components/modals/PasskeyNameModal';
|
||||
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import * as DateUtils from '~/utils/DateUtils';
|
||||
import * as WebAuthnUtils from '~/utils/WebAuthnUtils';
|
||||
import styles from './SecurityTab.module.css';
|
||||
|
||||
interface SecurityTabProps {
|
||||
user: UserRecord;
|
||||
isClaimed: boolean;
|
||||
hasSmsMfa: boolean;
|
||||
hasTotpMfa: boolean;
|
||||
isSmsMfaDisabledForUser: boolean;
|
||||
passkeys: Array<UserActionCreators.WebAuthnCredential>;
|
||||
loadingPasskeys: boolean;
|
||||
enablingSmsMfa: boolean;
|
||||
disablingSmsMfa: boolean;
|
||||
loadPasskeys: () => Promise<void>;
|
||||
setEnablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setDisablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
||||
({
|
||||
user,
|
||||
isClaimed,
|
||||
hasSmsMfa,
|
||||
hasTotpMfa,
|
||||
isSmsMfaDisabledForUser,
|
||||
passkeys,
|
||||
loadingPasskeys,
|
||||
enablingSmsMfa,
|
||||
disablingSmsMfa,
|
||||
loadPasskeys,
|
||||
setEnablingSmsMfa,
|
||||
setDisablingSmsMfa,
|
||||
}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
try {
|
||||
const options = await UserActionCreators.getWebAuthnRegistrationOptions();
|
||||
const credential = await WebAuthnUtils.performRegistration(options);
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<PasskeyNameModal
|
||||
onSubmit={async (name: string) => {
|
||||
await UserActionCreators.registerWebAuthnCredential(credential, options.challenge, name);
|
||||
await loadPasskeys();
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to add passkey', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenamePasskey = async (credentialId: string) => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<PasskeyNameModal
|
||||
onSubmit={async (name: string) => {
|
||||
try {
|
||||
await UserActionCreators.renameWebAuthnCredential(credentialId, name);
|
||||
await loadPasskeys();
|
||||
} catch (error) {
|
||||
console.error('Failed to rename passkey', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeletePasskey = (credentialId: string) => {
|
||||
const passkey = passkeys.find((p) => p.id === credentialId);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete Passkey`}
|
||||
description={
|
||||
passkey ? (
|
||||
<div>
|
||||
<Trans>
|
||||
Are you sure you want to delete the passkey <strong>{passkey.name}</strong>?
|
||||
</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Are you sure you want to delete this passkey?</Trans>
|
||||
)
|
||||
}
|
||||
primaryText={t`Delete Passkey`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await UserActionCreators.deleteWebAuthnCredential(credentialId);
|
||||
await loadPasskeys();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete passkey', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEnableSmsMfa = () => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Enable SMS Two-Factor Authentication`}
|
||||
description={
|
||||
<Trans>
|
||||
SMS two-factor authentication adds an additional layer of security to your account by requiring a
|
||||
verification code sent to your phone number when signing in.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={t`Enable SMS 2FA`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={async () => {
|
||||
setEnablingSmsMfa(true);
|
||||
try {
|
||||
await UserActionCreators.enableSmsMfa();
|
||||
} catch (error) {
|
||||
console.error('Failed to enable SMS MFA', error);
|
||||
} finally {
|
||||
setEnablingSmsMfa(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDisableSmsMfa = () => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Disable SMS Two-Factor Authentication`}
|
||||
description={
|
||||
<Trans>
|
||||
Are you sure you want to disable SMS two-factor authentication? This will make your account less secure.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={t`Disable SMS 2FA`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
setDisablingSmsMfa(true);
|
||||
try {
|
||||
await UserActionCreators.disableSmsMfa();
|
||||
} catch (error) {
|
||||
console.error('Failed to disable SMS MFA', error);
|
||||
} finally {
|
||||
setDisablingSmsMfa(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
if (!isClaimed) {
|
||||
return (
|
||||
<SettingsTabSection
|
||||
title={<Trans>Security Features</Trans>}
|
||||
description={
|
||||
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
|
||||
}
|
||||
>
|
||||
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Trans>Claim Account</Trans>
|
||||
</Button>
|
||||
</SettingsTabSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Two-Factor Authentication</Trans>}
|
||||
description={<Trans>Add an extra layer of security to your account</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Authenticator App</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{hasTotpMfa ? (
|
||||
<Trans>Two-factor authentication is enabled</Trans>
|
||||
) : (
|
||||
<Trans>Use an authenticator app to generate codes for two-factor authentication</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasTotpMfa ? (
|
||||
<Button
|
||||
variant="danger-secondary"
|
||||
small={true}
|
||||
onClick={() => ModalActionCreators.push(modal(() => <MfaTotpDisableModal />))}
|
||||
>
|
||||
<Trans>Disable</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <MfaTotpEnableModal />))}>
|
||||
<Trans>Enable</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTotpMfa && (
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Backup Codes</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Trans>View and manage your backup codes for account recovery</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
small={true}
|
||||
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesViewModal />))}
|
||||
>
|
||||
<Trans>View Codes</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Passkeys</Trans>}
|
||||
description={<Trans>Use passkeys for passwordless sign-in and two-factor authentication</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Registered Passkeys</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Plural
|
||||
value={passkeys.length}
|
||||
_0="No passkeys registered"
|
||||
one="# passkey registered (max 10)"
|
||||
other="# passkeys registered (max 10)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} disabled={loadingPasskeys || passkeys.length >= 10} onClick={handleAddPasskey}>
|
||||
<Trans>Add Passkey</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{passkeys.length > 0 && (
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.passkeyList}>
|
||||
{passkeys.map((passkey) => {
|
||||
const createdDate = DateUtils.getRelativeDateString(new Date(passkey.created_at), i18n);
|
||||
const lastUsedDate = passkey.last_used_at
|
||||
? DateUtils.getRelativeDateString(new Date(passkey.last_used_at), i18n)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={passkey.id} className={styles.passkeyItem}>
|
||||
<div className={styles.passkeyInfo}>
|
||||
<div className={styles.passkeyName}>{passkey.name}</div>
|
||||
<div className={styles.passkeyDetails}>
|
||||
{lastUsedDate ? (
|
||||
<Trans>
|
||||
Added: {createdDate} • Last used: {lastUsedDate}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Added: {createdDate}</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.passkeyActions}>
|
||||
<Button variant="secondary" small={true} onClick={() => handleRenamePasskey(passkey.id)}>
|
||||
<Trans>Rename</Trans>
|
||||
</Button>
|
||||
<Button variant="danger-secondary" small={true} onClick={() => handleDeletePasskey(passkey.id)}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
{user.mfaEnabled && (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Phone Number</Trans>}
|
||||
description={<Trans>Manage your phone number for SMS two-factor authentication</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>Phone Number</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{user.phone ? (
|
||||
<Trans>Phone number added: {user.phone}</Trans>
|
||||
) : (
|
||||
<Trans>Add a phone number to enable SMS two-factor authentication</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user.phone ? (
|
||||
<Button
|
||||
variant="danger-secondary"
|
||||
small={true}
|
||||
onClick={() => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Remove Phone Number`}
|
||||
description={
|
||||
<>
|
||||
<div>
|
||||
<Trans>
|
||||
Are you sure you want to remove your phone number <strong>{user.phone}</strong>?
|
||||
</Trans>
|
||||
</div>
|
||||
{hasSmsMfa && (
|
||||
<div>
|
||||
<Trans>
|
||||
<strong>Warning:</strong> This will also disable SMS two-factor authentication.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
primaryText={t`Remove Phone`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await UserActionCreators.removePhone();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove phone', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <PhoneAddModal />))}>
|
||||
<Trans>Add Phone</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
{user.phone && (
|
||||
<SettingsTabSection
|
||||
title={<Trans>SMS Two-Factor Authentication</Trans>}
|
||||
description={<Trans>Receive verification codes via SMS as a backup authentication method</Trans>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowContent}>
|
||||
<div className={styles.label}>
|
||||
<Trans>SMS Backup</Trans>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{hasSmsMfa ? (
|
||||
<Trans>SMS two-factor authentication is enabled</Trans>
|
||||
) : (
|
||||
<Trans>Enable SMS codes as a backup for your authenticator app</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasSmsMfa ? (
|
||||
<Button
|
||||
variant="danger-secondary"
|
||||
small={true}
|
||||
disabled={disablingSmsMfa}
|
||||
onClick={handleDisableSmsMfa}
|
||||
>
|
||||
<Trans>Disable</Trans>
|
||||
</Button>
|
||||
) : isSmsMfaDisabledForUser ? (
|
||||
<Tooltip text={t`SMS backup is disabled for partners`}>
|
||||
<div>
|
||||
<Button small={true} disabled={true}>
|
||||
<Trans>Enable</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button small={true} disabled={enablingSmsMfa} onClick={handleEnableSmsMfa}>
|
||||
<Trans>Enable</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
154
fluxer_app/src/components/modals/tabs/AdvancedTab.tsx
Normal file
154
fluxer_app/src/components/modals/tabs/AdvancedTab.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabContainer, SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
|
||||
import NativeWindowStateStore from '~/stores/NativeWindowStateStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import {getAutostartStatus, setAutostartEnabled} from '~/utils/AutostartUtils';
|
||||
import {getNativePlatform, isDesktop, type NativePlatform} from '~/utils/NativeUtils';
|
||||
|
||||
const AdvancedTab: React.FC = observer(() => {
|
||||
const {developerMode} = UserSettingsStore;
|
||||
const [autostartEnabled, setAutostartEnabledState] = React.useState(false);
|
||||
const [autostartBusy, setAutostartBusy] = React.useState(false);
|
||||
const [platform, setPlatform] = React.useState<NativePlatform>('unknown');
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initAutostart = async () => {
|
||||
if (!isDesktop()) return;
|
||||
|
||||
const detectedPlatform = await getNativePlatform();
|
||||
if (!mounted) return;
|
||||
|
||||
setPlatform(detectedPlatform);
|
||||
if (detectedPlatform !== 'macos') return;
|
||||
|
||||
setAutostartBusy(true);
|
||||
const enabled = await getAutostartStatus();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (enabled !== null) {
|
||||
setAutostartEnabledState(enabled);
|
||||
}
|
||||
setAutostartBusy(false);
|
||||
};
|
||||
|
||||
void initAutostart();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAutostartChange = async (value: boolean) => {
|
||||
if (platform !== 'macos') return;
|
||||
setAutostartBusy(true);
|
||||
const nextState = await setAutostartEnabled(value);
|
||||
if (nextState !== null) {
|
||||
setAutostartEnabledState(nextState);
|
||||
}
|
||||
setAutostartBusy(false);
|
||||
};
|
||||
|
||||
const showAutostartWarning = platform !== 'unknown' && platform !== 'macos';
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
{isDesktop() && (
|
||||
<SettingsTabSection
|
||||
title={<Trans>Desktop Startup</Trans>}
|
||||
description={<Trans>Run Fluxer automatically when your computer starts. Or don't. Your choice!</Trans>}
|
||||
>
|
||||
<Switch
|
||||
label={<Trans>Launch Fluxer at login</Trans>}
|
||||
description={<Trans>Applies only to the desktop app on this device.</Trans>}
|
||||
value={platform === 'macos' ? autostartEnabled : false}
|
||||
disabled={platform !== 'macos' || autostartBusy}
|
||||
onChange={handleAutostartChange}
|
||||
/>
|
||||
{showAutostartWarning && (
|
||||
<WarningAlert>
|
||||
<Trans>Autostart is coming soon for Windows and Linux. For now, it is only available on macOS.</Trans>
|
||||
</WarningAlert>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
{isDesktop() && (
|
||||
<SettingsTabSection
|
||||
title={<Trans>Desktop Window</Trans>}
|
||||
description={
|
||||
<Trans>Choose what Fluxer remembers about your window between restarts and reloads on this device.</Trans>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
label={<Trans>Remember size & position</Trans>}
|
||||
description={<Trans>Keep your window dimensions and placement even when you reload the app.</Trans>}
|
||||
value={NativeWindowStateStore.rememberSizeAndPosition}
|
||||
onChange={NativeWindowStateStore.setRememberSizeAndPosition}
|
||||
/>
|
||||
<Switch
|
||||
label={<Trans>Restore maximized</Trans>}
|
||||
description={<Trans>Reopen in maximized mode if that’s how you last used Fluxer.</Trans>}
|
||||
value={NativeWindowStateStore.rememberMaximized}
|
||||
onChange={NativeWindowStateStore.setRememberMaximized}
|
||||
/>
|
||||
<Switch
|
||||
label={<Trans>Restore fullscreen</Trans>}
|
||||
description={<Trans>Return to fullscreen automatically when you had it enabled last time.</Trans>}
|
||||
value={NativeWindowStateStore.rememberFullscreen}
|
||||
onChange={NativeWindowStateStore.setRememberFullscreen}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
<SettingsTabSection
|
||||
title={<Trans>Developer Options</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Enable advanced features for debugging and development. Note that copying snowflake IDs for entities is
|
||||
always available to all users without needing developer mode.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
label={<Trans>Developer Mode</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
When enabled, reveals debugging menus throughout the app to inspect and copy raw JSON objects of internal
|
||||
data structures like messages, channels, users, and communities. Also includes tools to debug the Fluxer
|
||||
Markdown parser performance and AST for any given message.
|
||||
</Trans>
|
||||
}
|
||||
value={developerMode}
|
||||
onChange={(value) => UserSettingsActionCreators.update({developerMode: value})}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default AdvancedTab;
|
||||
113
fluxer_app/src/components/modals/tabs/AppearanceTab.module.css
Normal file
113
fluxer_app/src/components/modals/tabs/AppearanceTab.module.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.previewWrapper {
|
||||
background: var(--background-secondary-lighter);
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 176px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 2px 12px 6px;
|
||||
padding-bottom: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.previewMessagesContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--chat-horizontal-padding: 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.previewContainerCompact .previewMessagesContainer,
|
||||
.previewContainerCozy .previewMessagesContainer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.previewMessagesContainer :global(.message),
|
||||
.previewMessagesContainer :global(.messageCompact) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
padding-right: var(--chat-horizontal-padding, 16px) !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.previewMessagesContainer > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.previewOverlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
cursor: default;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
height: 32px;
|
||||
background: linear-gradient(transparent, var(--background-secondary-lighter));
|
||||
}
|
||||
|
||||
.buttonRowsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: -1rem 0;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
height: 68px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.buttonRowsContainer > .buttonRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.buttonRowContent {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.buttonRowLabel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.buttonRowShortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
96
fluxer_app/src/components/modals/tabs/AppearanceTab.tsx
Normal file
96
fluxer_app/src/components/modals/tabs/AppearanceTab.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {FavoritesTabContent} from './AppearanceTab/FavoritesTab';
|
||||
import {InterfaceTabContent} from './AppearanceTab/InterfaceTab';
|
||||
import {AppearanceTabPreview, MessagesTabContent} from './AppearanceTab/MessagesTab';
|
||||
import {
|
||||
AppZoomLevelTabContent,
|
||||
ChatFontScalingTabContent,
|
||||
getAppZoomLevelDescription,
|
||||
shouldShowAppZoomLevel,
|
||||
} from './AppearanceTab/ScalingTab';
|
||||
import {ThemeTabContent} from './AppearanceTab/ThemeTab';
|
||||
|
||||
const AppearanceTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const showZoomLevel = shouldShowAppZoomLevel();
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection
|
||||
id="theme"
|
||||
title={t`Theme`}
|
||||
description={t`Choose between dark, coal, or light appearance. You can still add custom CSS overrides below for limitless control.`}
|
||||
>
|
||||
<ThemeTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="chat-font-scaling"
|
||||
title={t`Chat font scaling`}
|
||||
description={t`Adjust the font size in the chat area.`}
|
||||
>
|
||||
<ChatFontScalingTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
{showZoomLevel ? (
|
||||
<SettingsSection id="app-zoom-level" title={t`App zoom level`} description={getAppZoomLevelDescription(t)}>
|
||||
<AppZoomLevelTabContent />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
id="messages"
|
||||
title={t`Messages`}
|
||||
description={t`Choose how messages are displayed in chat channels.`}
|
||||
>
|
||||
<MessagesTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="interface"
|
||||
title={t`Interface`}
|
||||
description={t`Customize interface elements and behaviors.`}
|
||||
>
|
||||
<InterfaceTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="favorites"
|
||||
title={t`Favorites`}
|
||||
description={t`Control the visibility of favorites throughout the app.`}
|
||||
isAdvanced
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<FavoritesTabContent />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export {AppearanceTabPreview};
|
||||
export default AppearanceTab;
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
|
||||
export const FavoritesTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<Switch
|
||||
label={t`Enable Favorites`}
|
||||
description={t`When enabled, you can favorite channels and they'll appear in the Favorites section. When disabled, all favorite-related UI elements (buttons, menu items) will be hidden. Your existing favorites will be preserved.`}
|
||||
value={AccessibilityStore.showFavorites}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showFavorites: value})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import styles from './Inline.module.css';
|
||||
import {InterfaceTabContent} from './InterfaceTab';
|
||||
import {MessagesTabContent} from './MessagesTab';
|
||||
import {ThemeTabContent} from './ThemeTab';
|
||||
|
||||
export const AppearanceInlineTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SettingsSection id="appearance-theme" title={t`Theme`}>
|
||||
<ThemeTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="appearance-messages" title={t`Messages`}>
|
||||
<MessagesTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="appearance-interface" title={t`Interface`}>
|
||||
<InterfaceTabContent />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.switchWrapper {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
width: 100%;
|
||||
max-width: 16.5rem;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
max-width: 32rem;
|
||||
white-space: break-spaces;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.typingContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--surface-interactive-selected-color);
|
||||
}
|
||||
|
||||
.typingAnimationWrapper {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.typingAvatars {
|
||||
gap: 0;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Typing} from '~/components/channel/Typing';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {ChannelItemCore} from '~/components/layout/ChannelItem';
|
||||
import channelItemSurfaceStyles from '~/components/layout/ChannelItemSurface.module.css';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import AccessibilityStore, {ChannelTypingIndicatorMode} from '~/stores/AccessibilityStore';
|
||||
import {cdnUrl} from '~/utils/UrlUtils';
|
||||
import styles from './InterfaceTab.module.css';
|
||||
|
||||
const TYPING_PREVIEW_AVATAR_URLS = [1, 2, 3].map((index) => cdnUrl(`avatars/${index}.png`));
|
||||
|
||||
const ChannelListPreview = observer(({mode}: {mode: ChannelTypingIndicatorMode}) => {
|
||||
const typingIndicator =
|
||||
mode !== ChannelTypingIndicatorMode.HIDDEN ? (
|
||||
<Tooltip
|
||||
text={() => (
|
||||
<span className={styles.tooltipContent}>
|
||||
<strong>Kenji</strong>, <strong>Amara</strong> and <strong>Mateo</strong> are typing...
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<div className={styles.typingContainer}>
|
||||
<Typing className={styles.typingAnimationWrapper} color="var(--surface-interactive-selected-color)" />
|
||||
{mode === ChannelTypingIndicatorMode.AVATARS && (
|
||||
<AvatarStack size={12} maxVisible={5} className={styles.typingAvatars}>
|
||||
{TYPING_PREVIEW_AVATAR_URLS.map((avatarUrl, index) => (
|
||||
<MockAvatar key={avatarUrl} size={12} userTag={`User ${index + 1}`} avatarUrl={avatarUrl} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.previewContainer}>
|
||||
<div className={styles.previewContent}>
|
||||
<ChannelItemCore
|
||||
channel={{name: 'general', type: 0}}
|
||||
isSelected={true}
|
||||
typingIndicator={typingIndicator}
|
||||
className={clsx(
|
||||
'cursor-default',
|
||||
channelItemSurfaceStyles.channelItemSurface,
|
||||
channelItemSurfaceStyles.channelItemSurfaceSelected,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const InterfaceTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const channelTypingIndicatorOptions: ReadonlyArray<RadioOption<ChannelTypingIndicatorMode>> = [
|
||||
{
|
||||
value: ChannelTypingIndicatorMode.AVATARS,
|
||||
name: t`Typing indicator + avatars`,
|
||||
desc: t`Show typing indicator with user avatars in the channel list`,
|
||||
},
|
||||
{
|
||||
value: ChannelTypingIndicatorMode.INDICATOR_ONLY,
|
||||
name: t`Typing indicator only`,
|
||||
desc: t`Show just the typing indicator without avatars`,
|
||||
},
|
||||
{
|
||||
value: ChannelTypingIndicatorMode.HIDDEN,
|
||||
name: t`Hidden`,
|
||||
desc: t`Don't show typing indicators in the channel list`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title={t`Channel List Typing Indicators`}
|
||||
description={t`Choose how typing indicators appear in the channel list when someone is typing in a channel.`}
|
||||
>
|
||||
<ChannelListPreview mode={AccessibilityStore.channelTypingIndicatorMode} />
|
||||
<RadioGroup
|
||||
options={channelTypingIndicatorOptions}
|
||||
value={AccessibilityStore.channelTypingIndicatorMode}
|
||||
onChange={(value) => AccessibilityActionCreators.update({channelTypingIndicatorMode: value})}
|
||||
aria-label={t`Channel list typing indicator mode`}
|
||||
/>
|
||||
<div className={styles.switchWrapper}>
|
||||
<Switch
|
||||
label={t`Show typing on selected channel`}
|
||||
description={t`When disabled (default), typing indicators won't appear on the channel you're currently viewing.`}
|
||||
value={AccessibilityStore.showSelectedChannelTypingIndicator}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showSelectedChannelTypingIndicator: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={t`Keyboard Hints`}
|
||||
description={t`Control whether keyboard shortcut hints appear inside tooltips.`}
|
||||
>
|
||||
<div className={styles.switchWrapper}>
|
||||
<Switch
|
||||
label={t`Hide keyboard hints in tooltips`}
|
||||
description={t`When enabled, shortcut badges are hidden in tooltip popups.`}
|
||||
value={AccessibilityStore.hideKeyboardHints}
|
||||
onChange={(value) => AccessibilityActionCreators.update({hideKeyboardHints: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={t`Voice Channel Join Behavior`}
|
||||
description={t`Control how you join voice channels in communities.`}
|
||||
>
|
||||
<Switch
|
||||
label={t`Require double-click to join voice channels`}
|
||||
description={t`When enabled, you'll need to double-click on voice channels to join them. When disabled (default), single-clicking will join the channel immediately.`}
|
||||
value={AccessibilityStore.voiceChannelJoinRequiresDoubleClick}
|
||||
onChange={(value) =>
|
||||
AccessibilityActionCreators.update({
|
||||
voiceChannelJoinRequiresDoubleClick: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.switchWrapper {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants';
|
||||
import {Message} from '~/components/channel/Message';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import {MessageRecord} from '~/records/MessageRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {isNewMessageGroup} from '~/utils/MessageGroupingUtils';
|
||||
import appearanceTabStyles from '../AppearanceTab.module.css';
|
||||
import styles from './MessagesTab.module.css';
|
||||
|
||||
export const AppearanceTabPreview = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const {messageDisplayCompact} = UserSettingsStore;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const author = currentUser?.toJSON() || {
|
||||
id: 'preview-user',
|
||||
username: 'PreviewUser',
|
||||
discriminator: '0000',
|
||||
global_name: 'Preview User',
|
||||
avatar: null,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const fakeChannel = new ChannelRecord({
|
||||
id: 'fake-channel',
|
||||
type: 0,
|
||||
name: 'fake-channel',
|
||||
position: 0,
|
||||
parent_id: null,
|
||||
topic: null,
|
||||
url: null,
|
||||
nsfw: false,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
|
||||
const baseTime = new Date();
|
||||
const messageContents = [
|
||||
{content: t`This is how messages appear`, offsetMinutes: 0},
|
||||
{content: t`With different display modes available`, offsetMinutes: 1},
|
||||
{content: t`Customize the spacing and size`, offsetMinutes: 2},
|
||||
{content: t`Waiting for you to...`, offsetMinutes: 10},
|
||||
{content: t`... turn dense mode on. Nice!`, offsetMinutes: 11},
|
||||
];
|
||||
const fakeMessages = messageContents.map(({content, offsetMinutes}, index) => {
|
||||
const timestamp = new Date(baseTime.getTime() + offsetMinutes * 60 * 1000);
|
||||
return new MessageRecord(
|
||||
{
|
||||
id: `preview-${index + 1}`,
|
||||
channel_id: 'fake-channel',
|
||||
author,
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
content,
|
||||
timestamp: timestamp.toISOString(),
|
||||
state: MessageStates.SENT,
|
||||
},
|
||||
{skipUserCache: true},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={appearanceTabStyles.previewWrapper}>
|
||||
<div
|
||||
className={clsx(
|
||||
appearanceTabStyles.previewContainer,
|
||||
messageDisplayCompact
|
||||
? appearanceTabStyles.previewContainerCompact
|
||||
: appearanceTabStyles.previewContainerCozy,
|
||||
)}
|
||||
>
|
||||
<div className={appearanceTabStyles.previewMessagesContainer} key="appearance-messages-preview-scroller">
|
||||
{fakeMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? fakeMessages[index - 1] : undefined;
|
||||
const isNewGroup = isNewMessageGroup(fakeChannel, prevMessage, message);
|
||||
const shouldGroup = !messageDisplayCompact && !isNewGroup;
|
||||
return (
|
||||
<Message
|
||||
key={message.id}
|
||||
channel={fakeChannel}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
previewContext={MessagePreviewContext.SETTINGS}
|
||||
shouldGroup={shouldGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={appearanceTabStyles.previewOverlay} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const MessagesTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const {messageDisplayCompact} = UserSettingsStore;
|
||||
const messageGroupSpacing = AccessibilityStore.messageGroupSpacing;
|
||||
const showUserAvatarsInCompactMode = AccessibilityStore.showUserAvatarsInCompactMode;
|
||||
const showMessageDividers = AccessibilityStore.showMessageDividers;
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
|
||||
const messageDisplayOptions: ReadonlyArray<RadioOption<boolean>> = [
|
||||
{value: false, name: t`Comfy`, desc: t`Spacious layout with clear visual separation between messages.`},
|
||||
{value: true, name: t`Dense`, desc: t`Maximizes visible messages with minimal spacing.`},
|
||||
];
|
||||
|
||||
if (mobileLayout.enabled) {
|
||||
return <Trans>Message display settings are only available on desktop.</Trans>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RadioGroup
|
||||
options={messageDisplayOptions}
|
||||
value={messageDisplayCompact}
|
||||
onChange={(value) => {
|
||||
UserSettingsActionCreators.update({messageDisplayCompact: value});
|
||||
}}
|
||||
aria-label={t`Message display mode`}
|
||||
/>
|
||||
|
||||
{messageDisplayCompact ? (
|
||||
<div className={styles.switchWrapper}>
|
||||
<Switch
|
||||
label={t`Hide user avatars`}
|
||||
value={!showUserAvatarsInCompactMode}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showUserAvatarsInCompactMode: !value})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SettingsTabSection
|
||||
title={t`Space between message groups`}
|
||||
description={t`Adjust the spacing between groups of messages.`}
|
||||
>
|
||||
<Slider
|
||||
defaultValue={messageGroupSpacing}
|
||||
factoryDefaultValue={messageDisplayCompact ? 0 : 16}
|
||||
markers={[0, 4, 8, 16, 24]}
|
||||
stickToMarkers={true}
|
||||
onValueChange={(value) => AccessibilityActionCreators.update({messageGroupSpacing: value})}
|
||||
onMarkerRender={(value) => `${value}px`}
|
||||
/>
|
||||
<div className={styles.switchWrapper}>
|
||||
<Switch
|
||||
label={t`Show divider lines between message groups`}
|
||||
description={t`Display separator lines between different groups of messages for better visual distinction.`}
|
||||
value={showMessageDividers}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageDividers: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import KeybindStore from '~/stores/KeybindStore';
|
||||
import {formatKeyCombo} from '~/utils/KeybindUtils';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
|
||||
export const ChatFontScalingTabContent: React.FC = observer(() => {
|
||||
const fontSize = AccessibilityStore.fontSize;
|
||||
|
||||
return (
|
||||
<Slider
|
||||
defaultValue={fontSize}
|
||||
factoryDefaultValue={16}
|
||||
markers={[12, 14, 15, 16, 18, 20, 24]}
|
||||
stickToMarkers={true}
|
||||
onValueChange={(value) => AccessibilityActionCreators.update({fontSize: value})}
|
||||
onMarkerRender={(value) => `${value}px`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const AppZoomLevelTabContent: React.FC = observer(() => {
|
||||
const zoomLevel = AccessibilityStore.zoomLevel;
|
||||
|
||||
return (
|
||||
<Slider
|
||||
defaultValue={Math.round(zoomLevel * 100)}
|
||||
factoryDefaultValue={100}
|
||||
minValue={50}
|
||||
maxValue={200}
|
||||
step={10}
|
||||
markers={[50, 75, 100, 125, 150, 175, 200]}
|
||||
stickToMarkers={true}
|
||||
onValueChange={(value) => AccessibilityActionCreators.update({zoomLevel: value / 100})}
|
||||
onMarkerRender={(value) => `${value}%`}
|
||||
onValueRender={(value) => <Trans>{value}%</Trans>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type LinguiT = (literals: TemplateStringsArray, ...placeholders: Array<unknown>) => string;
|
||||
|
||||
export function getAppZoomLevelDescription(t: LinguiT): string {
|
||||
const zoomIn = formatKeyCombo(KeybindStore.keybinds.zoom_in);
|
||||
const zoomOut = formatKeyCombo(KeybindStore.keybinds.zoom_out);
|
||||
|
||||
return t`Adjust the overall zoom level of the app. Use ${zoomIn} / ${zoomOut} to adjust quickly.`;
|
||||
}
|
||||
|
||||
export function shouldShowAppZoomLevel(): boolean {
|
||||
return isDesktop();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.themeButtonGroup {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.themeButton {
|
||||
position: relative;
|
||||
height: 3.5rem;
|
||||
width: 3.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.themeButton:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.themeButton:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.themeButtonSelected {
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.themeButtonLight {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.themeButtonDark {
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.themeButtonIcon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.themeButtonCheckmark {
|
||||
position: absolute;
|
||||
top: -0.25rem;
|
||||
right: -0.25rem;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.themeButtonCheckmarkIcon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.colorGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.colorGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.colorSection {
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.colorSectionHeading {
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cssSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16rem;
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
563
fluxer_app/src/components/modals/tabs/AppearanceTab/ThemeTab.tsx
Normal file
563
fluxer_app/src/components/modals/tabs/AppearanceTab/ThemeTab.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowsCounterClockwiseIcon, CheckIcon, ShareNetworkIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {ThemeTypes} from '~/Constants';
|
||||
import {ColorPickerField} from '~/components/form/ColorPickerField';
|
||||
import {Input, Textarea} from '~/components/form/Input';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {ShareThemeModal} from '~/components/modals/ShareThemeModal';
|
||||
import {Accordion} from '~/components/uikit/Accordion/Accordion';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import styles from './ThemeTab.module.css';
|
||||
|
||||
interface ThemeButtonProps {
|
||||
themeType: string;
|
||||
currentTheme: string;
|
||||
label: string;
|
||||
backgroundColor: string;
|
||||
onKeyDown: (event: React.KeyboardEvent, themeType: string) => void;
|
||||
onClick: (themeType: string) => void;
|
||||
icon?: React.ReactElement<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const ThemeButton = observer(
|
||||
React.forwardRef<HTMLButtonElement, ThemeButtonProps>(
|
||||
({themeType, currentTheme, label, backgroundColor, onKeyDown, onClick, icon}, ref) => {
|
||||
const isSelected = currentTheme === themeType;
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const classes = [styles.themeButton];
|
||||
if (isSelected) {
|
||||
classes.push(styles.themeButtonSelected);
|
||||
} else if (backgroundColor === 'hsl(210, 20%, 98%)') {
|
||||
classes.push(styles.themeButtonLight);
|
||||
} else {
|
||||
classes.push(styles.themeButtonDark);
|
||||
}
|
||||
return clsx(classes);
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={() => onClick(themeType)}
|
||||
onKeyDown={(e) => onKeyDown(e, themeType)}
|
||||
className={getButtonClassName()}
|
||||
style={{backgroundColor}}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
aria-label={label}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
>
|
||||
{icon && (
|
||||
<div className={styles.themeButtonIcon} aria-hidden="true">
|
||||
{React.cloneElement(icon, {
|
||||
size: 24,
|
||||
weight: 'bold',
|
||||
style: {color: backgroundColor === 'hsl(220, 13%, 5%)' ? '#ffffff' : '#000000'},
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className={styles.themeButtonCheckmark} aria-hidden="true">
|
||||
<CheckIcon weight="bold" className={styles.themeButtonCheckmarkIcon} size={12} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const THEME_COLOR_VARIABLES: ReadonlyArray<string> = [
|
||||
'--background-primary',
|
||||
'--background-secondary',
|
||||
'--background-secondary-alt',
|
||||
'--background-tertiary',
|
||||
'--background-textarea',
|
||||
'--background-header-primary',
|
||||
'--background-header-primary-hover',
|
||||
'--background-header-secondary',
|
||||
'--background-modifier-hover',
|
||||
'--guild-list-foreground',
|
||||
'--background-modifier-selected',
|
||||
'--background-modifier-accent',
|
||||
'--background-modifier-accent-focus',
|
||||
'--brand-primary',
|
||||
'--brand-secondary',
|
||||
'--brand-primary-light',
|
||||
'--brand-primary-fill',
|
||||
'--status-online',
|
||||
'--status-idle',
|
||||
'--status-dnd',
|
||||
'--status-offline',
|
||||
'--status-danger',
|
||||
'--text-primary',
|
||||
'--text-secondary',
|
||||
'--text-tertiary',
|
||||
'--text-primary-muted',
|
||||
'--text-chat',
|
||||
'--text-chat-muted',
|
||||
'--text-link',
|
||||
'--text-on-brand-primary',
|
||||
'--text-tertiary-muted',
|
||||
'--text-tertiary-secondary',
|
||||
'--border-color',
|
||||
'--border-color-hover',
|
||||
'--border-color-focus',
|
||||
'--accent-primary',
|
||||
'--accent-success',
|
||||
'--accent-warning',
|
||||
'--accent-danger',
|
||||
'--accent-info',
|
||||
'--accent-purple',
|
||||
'--alert-note-color',
|
||||
'--alert-tip-color',
|
||||
'--alert-important-color',
|
||||
'--alert-warning-color',
|
||||
'--alert-caution-color',
|
||||
'--markup-mention-text',
|
||||
'--markup-mention-fill',
|
||||
'--markup-interactive-hover-text',
|
||||
'--markup-interactive-hover-fill',
|
||||
'--button-primary-fill',
|
||||
'--button-primary-active-fill',
|
||||
'--button-primary-text',
|
||||
'--button-secondary-fill',
|
||||
'--button-secondary-active-fill',
|
||||
'--button-secondary-text',
|
||||
'--button-secondary-active-text',
|
||||
'--button-danger-fill',
|
||||
'--button-danger-active-fill',
|
||||
'--button-danger-text',
|
||||
'--button-danger-outline-border',
|
||||
'--button-danger-outline-text',
|
||||
'--button-danger-outline-active-fill',
|
||||
'--button-danger-outline-active-border',
|
||||
'--button-ghost-text',
|
||||
'--button-inverted-fill',
|
||||
'--button-inverted-text',
|
||||
'--button-outline-border',
|
||||
'--button-outline-text',
|
||||
'--button-outline-active-fill',
|
||||
'--button-outline-active-border',
|
||||
'--bg-primary',
|
||||
'--bg-secondary',
|
||||
'--bg-tertiary',
|
||||
'--bg-hover',
|
||||
'--bg-active',
|
||||
'--bg-code',
|
||||
'--bg-code-block',
|
||||
'--bg-blockquote',
|
||||
'--bg-table-header',
|
||||
'--bg-table-row-odd',
|
||||
'--bg-table-row-even',
|
||||
];
|
||||
|
||||
const THEME_FONT_VARIABLES: ReadonlyArray<string> = ['--font-sans', '--font-mono'];
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractThemeVariableOverrides(css: string): Record<string, string> {
|
||||
const overrides: Record<string, string> = {};
|
||||
const variablePattern = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = variablePattern.exec(css)) !== null) {
|
||||
const variableName = `--${match[1] as string}`;
|
||||
const value = match[2] as string;
|
||||
overrides[variableName] = value.trim();
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function updateCssForVariable(css: string, variableName: string, newValue: string | null): string {
|
||||
const variableNamePattern = escapeRegExp(variableName);
|
||||
const propertyPattern = new RegExp(`(--${variableNamePattern.replace(/^--/, '')}\\s*:[^;]*;)`);
|
||||
|
||||
if (newValue === null) {
|
||||
return css.replace(propertyPattern, '');
|
||||
}
|
||||
|
||||
if (propertyPattern.test(css)) {
|
||||
return css.replace(propertyPattern, `${variableName}: ${newValue};`);
|
||||
}
|
||||
|
||||
const trimmedCss = css.trim();
|
||||
const prefix = trimmedCss.length > 0 && !trimmedCss.endsWith('\n') ? '\n' : '';
|
||||
return `${trimmedCss}${prefix}:root { ${variableName}: ${newValue}; }\n`;
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function numberToHex(value: number): string {
|
||||
return `#${(value >>> 0).toString(16).padStart(6, '0').slice(-6)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function cssColorStringToNumber(color: string): number | null {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) return null;
|
||||
|
||||
try {
|
||||
context.fillStyle = '#000';
|
||||
context.fillStyle = color;
|
||||
const parsed = String(context.fillStyle);
|
||||
|
||||
const match = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/i.exec(parsed);
|
||||
if (match) {
|
||||
const red = parseInt(match[1] ?? '0', 10);
|
||||
const green = parseInt(match[2] ?? '0', 10);
|
||||
const blue = parseInt(match[3] ?? '0', 10);
|
||||
const hex = `#${clampByte(red).toString(16).padStart(2, '0')}${clampByte(green)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${clampByte(blue).toString(16).padStart(2, '0')}`.toUpperCase();
|
||||
|
||||
return Number.parseInt(hex.slice(1), 16) >>> 0;
|
||||
}
|
||||
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(parsed)) {
|
||||
return Number.parseInt(parsed.slice(1), 16) >>> 0;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ThemeTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const {theme} = UserSettingsStore;
|
||||
const syncThemeAcrossDevices = AccessibilityStore.syncThemeAcrossDevices;
|
||||
const localThemeOverride = AccessibilityStore.localThemeOverride;
|
||||
const customThemeCss = AccessibilityStore.customThemeCss ?? '';
|
||||
|
||||
const currentSelectedTheme = syncThemeAcrossDevices ? theme : localThemeOverride || theme;
|
||||
|
||||
const [systemPrefersDark, setSystemPrefersDark] = React.useState(() => {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
const buttonRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const themeToFocusRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!window.matchMedia) return;
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setSystemPrefersDark(event.matches);
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const [defaultVariableValues, setDefaultVariableValues] = React.useState<Record<string, string> | null>(null);
|
||||
const beginThemeHydration = React.useCallback(() => {
|
||||
setDefaultVariableValues(null);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = React.useCallback(
|
||||
(newTheme: string) => {
|
||||
if (newTheme === currentSelectedTheme) return;
|
||||
themeToFocusRef.current = newTheme;
|
||||
beginThemeHydration();
|
||||
if (newTheme === ThemeTypes.SYSTEM) {
|
||||
AccessibilityActionCreators.update({syncThemeAcrossDevices: false, localThemeOverride: newTheme});
|
||||
} else if (syncThemeAcrossDevices) {
|
||||
UserSettingsActionCreators.update({theme: newTheme});
|
||||
} else {
|
||||
AccessibilityActionCreators.update({localThemeOverride: newTheme});
|
||||
}
|
||||
},
|
||||
[currentSelectedTheme, beginThemeHydration, syncThemeAcrossDevices],
|
||||
);
|
||||
|
||||
const themeOptions = [
|
||||
{
|
||||
type: ThemeTypes.DARK,
|
||||
label: t`Dark Theme`,
|
||||
backgroundColor: 'hsl(220, calc(13% * var(--saturation-factor)), 13.22%)',
|
||||
icon: null,
|
||||
tooltip: t`Use dark theme`,
|
||||
},
|
||||
{
|
||||
type: ThemeTypes.COAL,
|
||||
label: t`Coal Theme`,
|
||||
backgroundColor: 'hsl(220, 13%, 2%)',
|
||||
icon: null,
|
||||
tooltip: t`Use coal theme (pitch-black surfaces)`,
|
||||
},
|
||||
{
|
||||
type: ThemeTypes.LIGHT,
|
||||
label: t`Light Theme`,
|
||||
backgroundColor: 'hsl(210, 20%, 98%)',
|
||||
icon: null,
|
||||
tooltip: t`Use light theme`,
|
||||
},
|
||||
{
|
||||
type: ThemeTypes.SYSTEM,
|
||||
label: t`System Theme`,
|
||||
backgroundColor: systemPrefersDark ? 'hsl(220, 13%, 5%)' : 'hsl(210, 20%, 98%)',
|
||||
icon: <ArrowsCounterClockwiseIcon size={12} />,
|
||||
tooltip: systemPrefersDark
|
||||
? t`System: Dark theme (automatically sync with your system's dark/light preference)`
|
||||
: t`System: Light theme (automatically sync with your system's dark/light preference)`,
|
||||
},
|
||||
];
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent, targetTheme: string) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleThemeChange(targetTheme);
|
||||
} else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
const order = themeOptions.map((option) => option.type);
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1;
|
||||
const currentIndex = Math.max(order.indexOf(currentSelectedTheme as (typeof order)[number]), 0);
|
||||
const nextIndex = (currentIndex + direction + order.length) % order.length;
|
||||
const nextTheme = order[nextIndex];
|
||||
if (nextTheme) {
|
||||
handleThemeChange(nextTheme);
|
||||
}
|
||||
}
|
||||
},
|
||||
[themeOptions, currentSelectedTheme, handleThemeChange],
|
||||
);
|
||||
const overrides = React.useMemo(() => extractThemeVariableOverrides(customThemeCss), [customThemeCss]);
|
||||
const hydrationKey =
|
||||
currentSelectedTheme === ThemeTypes.SYSTEM
|
||||
? `${currentSelectedTheme}-${systemPrefersDark ? 'dark' : 'light'}`
|
||||
: currentSelectedTheme;
|
||||
|
||||
const lastHydrationKey = React.useRef(hydrationKey);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hydrationKey !== lastHydrationKey.current) {
|
||||
beginThemeHydration();
|
||||
lastHydrationKey.current = hydrationKey;
|
||||
}
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const nextDefaults: Record<string, string> = {};
|
||||
|
||||
for (const variableName of [...THEME_COLOR_VARIABLES, ...THEME_FONT_VARIABLES]) {
|
||||
const raw = computed.getPropertyValue(variableName);
|
||||
if (raw && raw.trim().length > 0) {
|
||||
nextDefaults[variableName] = raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultVariableValues(nextDefaults);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [hydrationKey, customThemeCss, beginThemeHydration]);
|
||||
|
||||
const handleShareTheme = React.useCallback(() => {
|
||||
const css = AccessibilityStore.customThemeCss ?? '';
|
||||
if (!css.trim()) {
|
||||
ToastActionCreators.error(t`You don't have any custom theme overrides to share yet.`);
|
||||
return;
|
||||
}
|
||||
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <ShareThemeModal themeCss={css} />));
|
||||
}, []);
|
||||
|
||||
const handleResetAllOverrides = React.useCallback(() => {
|
||||
AccessibilityActionCreators.update({customThemeCss: null});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultVariableValues !== null && themeToFocusRef.current) {
|
||||
const node = buttonRefs.current[themeToFocusRef.current];
|
||||
if (node) {
|
||||
node.focus();
|
||||
}
|
||||
themeToFocusRef.current = null;
|
||||
}
|
||||
}, [defaultVariableValues]);
|
||||
|
||||
if (defaultVariableValues === null) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.themeButtonGroup} role="radiogroup" aria-labelledby="theme-label">
|
||||
{themeOptions.map((option) => (
|
||||
<Tooltip key={option.type} text={option.tooltip} position="top" delay={200}>
|
||||
<div>
|
||||
<ThemeButton
|
||||
ref={(el) => {
|
||||
buttonRefs.current[option.type] = el;
|
||||
}}
|
||||
themeType={option.type}
|
||||
currentTheme={currentSelectedTheme}
|
||||
label={option.label}
|
||||
backgroundColor={option.backgroundColor}
|
||||
icon={option.icon ?? undefined}
|
||||
onClick={handleThemeChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Switch
|
||||
label={t`Sync theme across devices`}
|
||||
description={
|
||||
(syncThemeAcrossDevices ? theme : localThemeOverride) === ThemeTypes.SYSTEM
|
||||
? t`System theme automatically disables sync to track your system's preference on this device.`
|
||||
: t`When enabled, theme changes will sync to all your devices. When disabled, this device will use its own theme setting.`
|
||||
}
|
||||
value={syncThemeAcrossDevices}
|
||||
disabled={(syncThemeAcrossDevices ? theme : localThemeOverride) === ThemeTypes.SYSTEM}
|
||||
onChange={(value) => {
|
||||
if (!value) {
|
||||
const currentTheme = syncThemeAcrossDevices ? theme : UserSettingsStore.getTheme();
|
||||
AccessibilityActionCreators.update({syncThemeAcrossDevices: false, localThemeOverride: currentTheme});
|
||||
} else {
|
||||
AccessibilityActionCreators.update({syncThemeAcrossDevices: true, localThemeOverride: null});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
id="custom-theme-tokens"
|
||||
title={t`Custom theme tokens`}
|
||||
description={t`Fine-tune core colors and fonts for this app. Changes here are stored as custom CSS overrides and sync with the editor below.`}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className={styles.colorGrid}>
|
||||
{THEME_FONT_VARIABLES.map((variableName) => {
|
||||
const overrideValue = overrides[variableName];
|
||||
const defaultValue = defaultVariableValues[variableName];
|
||||
const currentValue = overrideValue ?? defaultValue ?? '';
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={variableName}
|
||||
label={
|
||||
variableName === '--font-sans'
|
||||
? t`Body font family`
|
||||
: variableName === '--font-mono'
|
||||
? t`Monospace font family`
|
||||
: variableName
|
||||
}
|
||||
placeholder={defaultValue || undefined}
|
||||
value={currentValue}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value.trim();
|
||||
const updatedCss =
|
||||
next.length === 0
|
||||
? updateCssForVariable(customThemeCss, variableName, null)
|
||||
: updateCssForVariable(customThemeCss, variableName, next);
|
||||
AccessibilityActionCreators.update({customThemeCss: updatedCss});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.colorSection}>
|
||||
<div className={styles.colorSectionHeading}>{t`Colors`}</div>
|
||||
<div className={styles.colorGrid}>
|
||||
{THEME_COLOR_VARIABLES.map((variableName) => {
|
||||
const overrideCss = overrides[variableName];
|
||||
const defaultCss = defaultVariableValues[variableName];
|
||||
|
||||
const defaultNumber = defaultCss !== undefined ? (cssColorStringToNumber(defaultCss) ?? 0) : undefined;
|
||||
const valueNumber =
|
||||
overrideCss !== undefined ? (cssColorStringToNumber(overrideCss) ?? defaultNumber ?? 0) : 0;
|
||||
|
||||
const label = variableName.replace(/^--/, '').replace(/-/g, ' ');
|
||||
|
||||
return (
|
||||
<ColorPickerField
|
||||
key={variableName}
|
||||
label={label}
|
||||
value={valueNumber ?? 0}
|
||||
defaultValue={defaultNumber}
|
||||
hideHelperText
|
||||
onChange={(nextValue) => {
|
||||
const updatedCss =
|
||||
nextValue === 0
|
||||
? updateCssForVariable(customThemeCss, variableName, null)
|
||||
: updateCssForVariable(customThemeCss, variableName, numberToHex(nextValue));
|
||||
AccessibilityActionCreators.update({customThemeCss: updatedCss});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.cssSection}>
|
||||
<Textarea
|
||||
label={t`Custom CSS overrides`}
|
||||
placeholder={t`Write custom CSS here to override any theme tokens. For example:\n:root { --background-primary: #1E1E2F; }`}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
value={customThemeCss}
|
||||
onChange={(event) => {
|
||||
AccessibilityActionCreators.update({customThemeCss: event.target.value});
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button variant="secondary" fitContent onClick={handleResetAllOverrides}>
|
||||
{t`Reset all overrides to theme default`}
|
||||
</Button>
|
||||
<Button variant="primary" fitContent leftIcon={<ShareNetworkIcon size={18} />} onClick={handleShareTheme}>
|
||||
{t`Share this theme`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
|
||||
interface ApplicationCreateModalProps {
|
||||
onCreated: (application: DeveloperApplication) => void;
|
||||
}
|
||||
|
||||
interface CreateFormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const ApplicationCreateModal: React.FC<ApplicationCreateModalProps> = observer(({onCreated}) => {
|
||||
const {t} = useLingui();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: {errors},
|
||||
reset,
|
||||
} = useForm<CreateFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const nameField = register('name', {required: true, maxLength: 100});
|
||||
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [createError, setCreateError] = React.useState<string | null>(null);
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
setCreateError(null);
|
||||
ModalActionCreators.pop();
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await HttpClient.post<DeveloperApplication>(Endpoints.OAUTH_APPLICATIONS, {
|
||||
name: data.name.trim(),
|
||||
redirect_uris: [],
|
||||
});
|
||||
onCreated(response.body);
|
||||
reset();
|
||||
ModalActionCreators.pop();
|
||||
} catch (err) {
|
||||
console.error('[ApplicationCreateModal] Failed to create application:', err);
|
||||
setCreateError(t`Failed to create application. Please check your inputs and try again.`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal.Root size="small" centered initialFocusRef={nameInputRef}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Header title={t`Create Application`} />
|
||||
<Modal.Content className={styles.createForm}>
|
||||
<Input
|
||||
type="text"
|
||||
label={t`Application Name`}
|
||||
{...nameField}
|
||||
ref={(el) => {
|
||||
nameField.ref(el);
|
||||
nameInputRef.current = el;
|
||||
}}
|
||||
placeholder={t`My Application`}
|
||||
maxLength={100}
|
||||
required
|
||||
disabled={creating}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{errors.name && <div className={styles.error}>{t`Application name is required`}</div>}
|
||||
{createError && <div className={styles.error}>{createError}</div>}
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<Button type="button" variant="secondary" onClick={handleCancel} disabled={creating}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" submitting={creating}>
|
||||
{t`Create`}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,691 @@
|
||||
/*
|
||||
* 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 {TrashIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {useForm, useWatch} from 'react-hook-form';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCreators';
|
||||
import {OAuth2Scopes, UserPremiumTypes} from '~/Constants';
|
||||
import {Form} from '~/components/form/Form';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import {useFormSubmit} from '~/hooks/useFormSubmit';
|
||||
import {useSudo} from '~/hooks/useSudo';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import {formatBotPermissionsQuery, getAllBotPermissions} from '~/utils/PermissionUtils';
|
||||
import styles from './application-detail/ApplicationDetail.module.css';
|
||||
import {ApplicationHeader} from './application-detail/ApplicationHeader';
|
||||
import {ApplicationInfoSection} from './application-detail/ApplicationInfoSection';
|
||||
import {BotProfileSection} from './application-detail/BotProfileSection';
|
||||
import {OAuthBuilderSection} from './application-detail/OAuthBuilderSection';
|
||||
import {SecretsSection} from './application-detail/SecretsSection';
|
||||
import {SectionCard} from './application-detail/SectionCard';
|
||||
import type {ApplicationDetailFormValues} from './application-detail/types';
|
||||
|
||||
interface ApplicationDetailProps {
|
||||
applicationId: string;
|
||||
onBack: () => void;
|
||||
initialApplication?: DeveloperApplication | null;
|
||||
}
|
||||
|
||||
const APPLICATIONS_TAB_ID = 'applications';
|
||||
const AVAILABLE_SCOPES = OAuth2Scopes.filter((scope) => scope !== 'applications.commands');
|
||||
|
||||
export const ApplicationDetail: React.FC<ApplicationDetailProps> = observer(
|
||||
({applicationId, onBack, initialApplication}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const store = ApplicationsTabStore;
|
||||
|
||||
const application = store.selectedApplication;
|
||||
const loading = store.isLoading && store.isDetailView;
|
||||
const error = store.error;
|
||||
const [idCopied, setIdCopied] = React.useState(false);
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = React.useState<string | null>(null);
|
||||
const [hasClearedAvatar, setHasClearedAvatar] = React.useState(false);
|
||||
const [previewBannerUrl, setPreviewBannerUrl] = React.useState<string | null>(null);
|
||||
const [hasClearedBanner, setHasClearedBanner] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [initialValues, setInitialValues] = React.useState<ApplicationDetailFormValues | null>(null);
|
||||
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
|
||||
const [botToken, setBotToken] = React.useState<string | null>(null);
|
||||
const [isRotating, setIsRotating] = React.useState<'client' | 'bot' | null>(null);
|
||||
const clientSecretInputId = React.useId();
|
||||
const botTokenInputId = React.useId();
|
||||
|
||||
const currentUser = UserStore.currentUser;
|
||||
const isLifetimePremium = currentUser?.premiumType === UserPremiumTypes.LIFETIME;
|
||||
const sudo = useSudo();
|
||||
|
||||
const form = useForm<ApplicationDetailFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
botPublic: true,
|
||||
redirectUris: [],
|
||||
redirectUriInputs: [''],
|
||||
builderScopes: {} as Record<string, boolean>,
|
||||
builderPermissions: {} as Record<string, boolean>,
|
||||
username: '',
|
||||
discriminator: '',
|
||||
avatar: null,
|
||||
bio: '',
|
||||
banner: null,
|
||||
},
|
||||
});
|
||||
|
||||
const buildFormDefaults = React.useCallback((app: DeveloperApplication): ApplicationDetailFormValues => {
|
||||
const builderScopeMap = AVAILABLE_SCOPES.reduce<Record<string, boolean>>((acc, scope) => {
|
||||
acc[scope] = false;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const redirectList = (app.redirect_uris ?? []).length > 0 ? app.redirect_uris : [''];
|
||||
|
||||
return {
|
||||
name: app.name,
|
||||
redirectUris: app.redirect_uris ?? [],
|
||||
redirectUriInputs: redirectList,
|
||||
botPublic: app.bot_public,
|
||||
builderScopes: builderScopeMap,
|
||||
builderPermissions: {},
|
||||
username: app.bot?.username || '',
|
||||
discriminator: app.bot?.discriminator || '',
|
||||
avatar: null,
|
||||
bio: app.bot?.bio ?? '',
|
||||
banner: null,
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (application && application.id === applicationId) {
|
||||
const fetchedClientSecret = application.client_secret ?? null;
|
||||
const fetchedBotToken = application.bot?.token ?? null;
|
||||
setClientSecret(fetchedClientSecret);
|
||||
setBotToken(fetchedBotToken);
|
||||
|
||||
const defaults = buildFormDefaults(application);
|
||||
form.reset(defaults);
|
||||
setInitialValues(defaults);
|
||||
|
||||
setPreviewAvatarUrl(null);
|
||||
setHasClearedAvatar(false);
|
||||
setPreviewBannerUrl(null);
|
||||
setHasClearedBanner(false);
|
||||
}
|
||||
}, [application, applicationId, buildFormDefaults, form]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialApplication && initialApplication.id === applicationId) {
|
||||
void store.navigateToDetail(applicationId, initialApplication);
|
||||
} else if (!store.selectedApplication || store.selectedAppId !== applicationId) {
|
||||
void store.navigateToDetail(applicationId);
|
||||
}
|
||||
}, [applicationId, initialApplication, store]);
|
||||
|
||||
const formIsSubmitting = form.formState.isSubmitting;
|
||||
const watchedValues = useWatch<ApplicationDetailFormValues>({control: form.control});
|
||||
|
||||
const hasFormChanges = React.useMemo(() => {
|
||||
if (!initialValues) return false;
|
||||
const currentValues =
|
||||
(watchedValues as ApplicationDetailFormValues | undefined) ?? ({} as ApplicationDetailFormValues);
|
||||
|
||||
return (
|
||||
(currentValues.name ?? '') !== (initialValues.name ?? '') ||
|
||||
(currentValues.redirectUris ?? []).join(',') !== (initialValues.redirectUris ?? []).join(',') ||
|
||||
(currentValues.redirectUriInputs ?? []).join(',') !== (initialValues.redirectUriInputs ?? []).join(',') ||
|
||||
(currentValues.botPublic ?? true) !== (initialValues.botPublic ?? true) ||
|
||||
(currentValues.username ?? '') !== (initialValues.username ?? '') ||
|
||||
(currentValues.discriminator ?? '') !== (initialValues.discriminator ?? '') ||
|
||||
(currentValues.bio ?? '') !== (initialValues.bio ?? '') ||
|
||||
(currentValues.banner ?? '') !== (initialValues.banner ?? '')
|
||||
);
|
||||
}, [initialValues, watchedValues]);
|
||||
|
||||
const hasUnsavedChanges = React.useMemo(() => {
|
||||
return Boolean(hasFormChanges || previewAvatarUrl || hasClearedAvatar || previewBannerUrl || hasClearedBanner);
|
||||
}, [hasFormChanges, previewAvatarUrl, hasClearedAvatar, previewBannerUrl, hasClearedBanner]);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (data: ApplicationDetailFormValues) => {
|
||||
if (!application) return;
|
||||
|
||||
const normalizedName = data.name.trim();
|
||||
const redirectUris = (data.redirectUriInputs ?? []).map((u) => u.trim()).filter(Boolean);
|
||||
const dirtyFields = form.formState.dirtyFields;
|
||||
|
||||
const buildApplicationPatch = () => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (normalizedName !== application.name) {
|
||||
changes.name = normalizedName;
|
||||
}
|
||||
const initialRedirects = application.redirect_uris ?? [];
|
||||
if ((redirectUris ?? []).join(',') !== initialRedirects.join(',')) {
|
||||
changes.redirect_uris = redirectUris;
|
||||
}
|
||||
if ((data.botPublic ?? true) !== (application.bot_public ?? true)) {
|
||||
changes.bot_public = data.botPublic;
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
const buildBotPatch = () => {
|
||||
if (!application.bot) return null;
|
||||
|
||||
const botBody: Record<string, unknown> = {};
|
||||
const currentBot = application.bot;
|
||||
const avatarCleared = hasClearedAvatar;
|
||||
const bannerCleared = hasClearedBanner;
|
||||
|
||||
if (dirtyFields.username && data.username && data.username !== currentBot.username) {
|
||||
botBody.username = data.username;
|
||||
}
|
||||
|
||||
const currentDiscriminator = currentBot.discriminator || '';
|
||||
if (
|
||||
isLifetimePremium &&
|
||||
dirtyFields.discriminator &&
|
||||
data.discriminator &&
|
||||
data.discriminator !== currentDiscriminator
|
||||
) {
|
||||
botBody.discriminator = data.discriminator;
|
||||
}
|
||||
|
||||
const shouldSendAvatar = dirtyFields.avatar || avatarCleared;
|
||||
if (shouldSendAvatar) {
|
||||
if (avatarCleared) {
|
||||
botBody.avatar = null;
|
||||
} else if (data.avatar) {
|
||||
botBody.avatar = data.avatar;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSendBanner = dirtyFields.banner || bannerCleared;
|
||||
if (shouldSendBanner) {
|
||||
if (bannerCleared) {
|
||||
botBody.banner = null;
|
||||
} else if (data.banner) {
|
||||
botBody.banner = data.banner;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyFields.bio) {
|
||||
const trimmedBio = data.bio?.trim() ?? '';
|
||||
const currentBio = currentBot.bio ?? '';
|
||||
if (trimmedBio !== currentBio) {
|
||||
botBody.bio = trimmedBio.length > 0 ? trimmedBio : null;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(botBody).length > 0 ? botBody : null;
|
||||
};
|
||||
|
||||
const appPatch = buildApplicationPatch();
|
||||
const botPatch = buildBotPatch();
|
||||
|
||||
if (Object.keys(appPatch).length === 0 && !botPatch) {
|
||||
ToastActionCreators.createToast({type: 'info', children: t`No changes to save`});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Object.keys(appPatch).length > 0) {
|
||||
await HttpClient.patch(Endpoints.OAUTH_APPLICATION(applicationId), appPatch);
|
||||
}
|
||||
|
||||
if (botPatch) {
|
||||
await HttpClient.patch(Endpoints.OAUTH_APPLICATION_BOT_PROFILE(applicationId), botPatch);
|
||||
}
|
||||
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Application updated successfully`});
|
||||
setPreviewAvatarUrl(null);
|
||||
setHasClearedAvatar(false);
|
||||
await store.fetchApplication(applicationId);
|
||||
} catch (err) {
|
||||
console.error('[ApplicationDetail] Failed to update application:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[
|
||||
application,
|
||||
applicationId,
|
||||
store,
|
||||
form.formState.dirtyFields,
|
||||
hasClearedAvatar,
|
||||
hasClearedBanner,
|
||||
isLifetimePremium,
|
||||
],
|
||||
);
|
||||
|
||||
const {handleSubmit: handleSave} = useFormSubmit({
|
||||
form,
|
||||
onSubmit,
|
||||
defaultErrorField: 'name',
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
if (!application) return;
|
||||
|
||||
const defaults = buildFormDefaults(application);
|
||||
form.reset(defaults, {keepDirty: false});
|
||||
setInitialValues(defaults);
|
||||
setPreviewAvatarUrl(null);
|
||||
setHasClearedAvatar(false);
|
||||
setPreviewBannerUrl(null);
|
||||
setHasClearedBanner(false);
|
||||
}, [application, buildFormDefaults, form]);
|
||||
|
||||
React.useEffect(() => {
|
||||
UnsavedChangesActionCreators.setUnsavedChanges(APPLICATIONS_TAB_ID, hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
React.useEffect(() => {
|
||||
UnsavedChangesActionCreators.setTabData(APPLICATIONS_TAB_ID, {
|
||||
onReset: handleReset,
|
||||
onSave: handleSave,
|
||||
isSubmitting: formIsSubmitting,
|
||||
});
|
||||
}, [handleReset, handleSave, formIsSubmitting]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
UnsavedChangesActionCreators.clearUnsavedChanges(APPLICATIONS_TAB_ID);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAvatarChange = React.useCallback(
|
||||
(base64: string) => {
|
||||
form.setValue('avatar', base64, {shouldDirty: true});
|
||||
setPreviewAvatarUrl(base64);
|
||||
setHasClearedAvatar(false);
|
||||
form.clearErrors('avatar');
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleBannerChange = React.useCallback(
|
||||
(base64: string) => {
|
||||
form.setValue('banner', base64, {shouldDirty: true});
|
||||
setPreviewBannerUrl(base64);
|
||||
setHasClearedBanner(false);
|
||||
form.clearErrors('banner');
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleBannerClear = React.useCallback(() => {
|
||||
form.setValue('banner', null, {shouldDirty: true});
|
||||
setPreviewBannerUrl(null);
|
||||
setHasClearedBanner(true);
|
||||
}, [form]);
|
||||
|
||||
const handleAvatarClear = React.useCallback(() => {
|
||||
form.setValue('avatar', null, {shouldDirty: true});
|
||||
setPreviewAvatarUrl(null);
|
||||
setHasClearedAvatar(true);
|
||||
}, [form]);
|
||||
|
||||
const handleCopyId = async () => {
|
||||
if (!application) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(application.id);
|
||||
setIdCopied(true);
|
||||
setTimeout(() => setIdCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('[ApplicationDetail] Failed to copy ID:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!application) return;
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete Application`}
|
||||
description={
|
||||
<div>
|
||||
<Trans>
|
||||
Are you sure you want to delete <strong>{application.name}</strong>?
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<Trans>
|
||||
This action cannot be undone. All associated data, including the bot user, will be permanently
|
||||
deleted.
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
primaryText={t`Delete Application`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await HttpClient.delete({url: Endpoints.OAUTH_APPLICATION(application.id), body: {}});
|
||||
onBack();
|
||||
} catch (err) {
|
||||
console.error('[ApplicationDetail] Failed to delete application:', err);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Failed to delete application. Please try again.`,
|
||||
});
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const rotateSecret = async (type: 'client' | 'bot') => {
|
||||
if (!application) return;
|
||||
setIsRotating(type);
|
||||
try {
|
||||
const sudoPayload = await sudo.require();
|
||||
const endpoint =
|
||||
type === 'client'
|
||||
? Endpoints.OAUTH_APPLICATION_CLIENT_SECRET_RESET(application.id)
|
||||
: Endpoints.OAUTH_APPLICATION_BOT_TOKEN_RESET(application.id);
|
||||
const res = await HttpClient.post<{client_secret?: string; token?: string}>(endpoint, sudoPayload);
|
||||
if (type === 'client') {
|
||||
setClientSecret(res.body.client_secret ?? null);
|
||||
} else {
|
||||
setBotToken(res.body.token ?? null);
|
||||
}
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children:
|
||||
type === 'client'
|
||||
? t`Client secret regenerated. Update any code that uses the old secret.`
|
||||
: t`Bot token regenerated. Update any code that uses the old token.`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate secret', err);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Failed to rotate. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
setIsRotating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addRedirectInput = () => {
|
||||
const current = form.getValues('redirectUriInputs') ?? [];
|
||||
form.setValue('redirectUriInputs', [...current, ''], {shouldDirty: true});
|
||||
};
|
||||
|
||||
const removeRedirectInput = (index: number) => {
|
||||
const current = form.getValues('redirectUriInputs') ?? [];
|
||||
const next = current.filter((_, i) => i !== index);
|
||||
form.setValue('redirectUriInputs', next.length > 0 ? next : [''], {shouldDirty: true});
|
||||
};
|
||||
|
||||
const updateRedirectInput = (index: number, value: string) => {
|
||||
const current = form.getValues('redirectUriInputs') ?? [];
|
||||
const next = [...current];
|
||||
next[index] = value;
|
||||
form.setValue('redirectUriInputs', next, {shouldDirty: true});
|
||||
};
|
||||
|
||||
const builderScopes = useWatch({control: form.control, name: 'builderScopes'}) || {};
|
||||
const builderPermissions = useWatch({control: form.control, name: 'builderPermissions'}) || {};
|
||||
const builderRedirectUri = useWatch({control: form.control, name: 'builderRedirectUri'});
|
||||
const redirectInputs = useWatch({control: form.control, name: 'redirectUriInputs'}) ?? [];
|
||||
const bannerValue = useWatch({control: form.control, name: 'banner'});
|
||||
|
||||
const builderScopeList = React.useMemo(
|
||||
() =>
|
||||
Object.entries(builderScopes)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([scope]) => scope),
|
||||
[builderScopes],
|
||||
);
|
||||
|
||||
const botPermissionsList = React.useMemo(() => getAllBotPermissions(i18n), [i18n]);
|
||||
|
||||
const builderUrl = React.useMemo(() => {
|
||||
if (!application) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('client_id', application.id);
|
||||
|
||||
if (builderScopeList.length > 0) {
|
||||
params.set('scope', builderScopeList.join(' '));
|
||||
}
|
||||
|
||||
const isBot = builderScopeList.includes('bot');
|
||||
const botPerms = Object.entries(builderPermissions)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([perm]) => perm);
|
||||
if (isBot && botPerms.length > 0) {
|
||||
params.set('permissions', formatBotPermissionsQuery(botPerms));
|
||||
}
|
||||
|
||||
const redirect = builderRedirectUri?.trim();
|
||||
if (redirect) {
|
||||
params.set('redirect_uri', redirect);
|
||||
params.set('response_type', 'code');
|
||||
} else {
|
||||
if (!isBot) {
|
||||
params.set('response_type', 'code');
|
||||
}
|
||||
}
|
||||
|
||||
if (builderScopeList.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${window.location.origin}${Endpoints.OAUTH_AUTHORIZE}?${params.toString()}`;
|
||||
}, [application, builderScopeList, builderPermissions, builderRedirectUri]);
|
||||
|
||||
const redirectOptions = React.useMemo(() => {
|
||||
const normalized = Array.from(new Set((redirectInputs ?? []).map((u) => u.trim()).filter(Boolean)));
|
||||
const current = builderRedirectUri?.trim();
|
||||
if (current && !normalized.includes(current)) {
|
||||
normalized.push(current);
|
||||
}
|
||||
return normalized.map((url) => ({value: url, label: url}));
|
||||
}, [builderRedirectUri, redirectInputs]);
|
||||
|
||||
const confirmRotate = (type: 'client' | 'bot') => {
|
||||
if (!application) return;
|
||||
const isClient = type === 'client';
|
||||
const description = isClient ? (
|
||||
<Trans>Regenerating will invalidate the current secret. Update any code that uses the old value.</Trans>
|
||||
) : (
|
||||
<Trans>Regenerating will invalidate the current token. Update any code that uses the old value.</Trans>
|
||||
);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={isClient ? t`Regenerate client secret?` : t`Regenerate bot token?`}
|
||||
description={description}
|
||||
primaryText={t`Regenerate`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={() => rotateSecret(type)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyBuilderUrl = React.useCallback(async () => {
|
||||
if (!builderUrl) return;
|
||||
await navigator.clipboard.writeText(builderUrl);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Copied URL to clipboard`,
|
||||
});
|
||||
}, [builderUrl]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loadingState} />;
|
||||
}
|
||||
|
||||
if (error || !application) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<StatusSlate
|
||||
Icon={WarningCircleIcon}
|
||||
title={<Trans>We couldn't load this application</Trans>}
|
||||
description={<Trans>Please retry or go back to the applications list.</Trans>}
|
||||
fullHeight={true}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Retry</Trans>,
|
||||
onClick: () => store.fetchApplication(applicationId),
|
||||
},
|
||||
{
|
||||
text: <Trans>Back to list</Trans>,
|
||||
onClick: onBack,
|
||||
variant: 'secondary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarUrl = application.bot
|
||||
? AvatarUtils.getUserAvatarURL({id: application.bot.id, avatar: application.bot.avatar}, false)
|
||||
: null;
|
||||
const defaultAvatarUrl = application.bot
|
||||
? AvatarUtils.getUserAvatarURL({id: application.bot.id, avatar: null}, false)
|
||||
: null;
|
||||
|
||||
const displayAvatarUrl = hasClearedAvatar ? defaultAvatarUrl : previewAvatarUrl || avatarUrl || defaultAvatarUrl;
|
||||
const hasAvatar = (!hasClearedAvatar && Boolean(application.bot?.avatar)) || Boolean(previewAvatarUrl);
|
||||
const displayBannerUrl =
|
||||
previewBannerUrl ||
|
||||
(hasClearedBanner
|
||||
? null
|
||||
: application.bot
|
||||
? AvatarUtils.getUserBannerURL({id: application.bot.id, banner: application.bot.banner}, true)
|
||||
: null);
|
||||
const hasBanner = Boolean(displayBannerUrl || bannerValue);
|
||||
|
||||
return (
|
||||
<Form form={form} onSubmit={onSubmit}>
|
||||
<div className={styles.page}>
|
||||
<ApplicationHeader
|
||||
name={application.name}
|
||||
applicationId={application.id}
|
||||
onBack={onBack}
|
||||
onCopyId={handleCopyId}
|
||||
idCopied={idCopied}
|
||||
/>
|
||||
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.columnStack}>
|
||||
<SecretsSection
|
||||
clientSecret={clientSecret}
|
||||
botToken={botToken}
|
||||
onRegenerateClientSecret={() => confirmRotate('client')}
|
||||
onRegenerateBotToken={() => confirmRotate('bot')}
|
||||
isRotatingClient={isRotating === 'client'}
|
||||
isRotatingBot={isRotating === 'bot'}
|
||||
hasBot={Boolean(application.bot)}
|
||||
clientSecretInputId={clientSecretInputId}
|
||||
botTokenInputId={botTokenInputId}
|
||||
/>
|
||||
<div className={styles.sectionSpacer} aria-hidden="true" />
|
||||
|
||||
<ApplicationInfoSection
|
||||
form={form}
|
||||
redirectInputs={form.watch('redirectUriInputs') ?? []}
|
||||
onAddRedirect={addRedirectInput}
|
||||
onRemoveRedirect={removeRedirectInput}
|
||||
onUpdateRedirect={updateRedirectInput}
|
||||
/>
|
||||
|
||||
{application.bot && (
|
||||
<>
|
||||
<div className={styles.sectionSpacer} aria-hidden="true" />
|
||||
<BotProfileSection
|
||||
application={application}
|
||||
form={form}
|
||||
displayAvatarUrl={displayAvatarUrl}
|
||||
hasAvatar={hasAvatar}
|
||||
hasClearedAvatar={hasClearedAvatar}
|
||||
isLifetimePremium={Boolean(isLifetimePremium)}
|
||||
onAvatarChange={handleAvatarChange}
|
||||
onAvatarClear={handleAvatarClear}
|
||||
onBannerChange={handleBannerChange}
|
||||
onBannerClear={handleBannerClear}
|
||||
displayBannerUrl={displayBannerUrl}
|
||||
hasBanner={hasBanner}
|
||||
hasClearedBanner={hasClearedBanner}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.columnStack}>
|
||||
<div className={styles.sectionSpacer} aria-hidden="true" />
|
||||
<div>
|
||||
<OAuthBuilderSection
|
||||
form={form}
|
||||
availableScopes={AVAILABLE_SCOPES}
|
||||
builderScopeList={builderScopeList}
|
||||
botPermissionsList={botPermissionsList}
|
||||
builderUrl={builderUrl}
|
||||
redirectOptions={redirectOptions}
|
||||
onCopyBuilderUrl={handleCopyBuilderUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionSpacer} aria-hidden="true" />
|
||||
|
||||
<SectionCard
|
||||
tone="danger"
|
||||
title={<Trans>Danger zone</Trans>}
|
||||
subtitle={<Trans>This cannot be undone. Removing the application also deletes its bot.</Trans>}
|
||||
>
|
||||
<div className={styles.dangerContent}>
|
||||
<p className={styles.helperText}>
|
||||
<Trans>Once deleted, the application and its credentials are permanently removed.</Trans>
|
||||
</p>
|
||||
<div className={styles.dangerActions}>
|
||||
<Button
|
||||
variant="danger-primary"
|
||||
onClick={handleDelete}
|
||||
submitting={isDeleting}
|
||||
leftIcon={<TrashIcon size={16} weight="fill" />}
|
||||
fitContent
|
||||
>
|
||||
<Trans>Delete Application</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {AppWindowIcon, CaretRightIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import * as DateUtils from '~/utils/DateUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
interface ApplicationsListProps {
|
||||
applications: ReadonlyArray<DeveloperApplication>;
|
||||
onSelectApplication: (appId: string) => void;
|
||||
}
|
||||
|
||||
export const ApplicationsList: React.FC<ApplicationsListProps> = observer(({applications, onSelectApplication}) => {
|
||||
if (applications.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<StatusSlate
|
||||
Icon={AppWindowIcon}
|
||||
title={<Trans>No applications yet</Trans>}
|
||||
description={<Trans>Create your first application to get started with the Fluxer API.</Trans>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listContainer}>
|
||||
{applications.map((app) => {
|
||||
const avatarUrl = app.bot
|
||||
? AvatarUtils.getUserAvatarURL({id: app.bot.id, avatar: app.bot.avatar}, false)
|
||||
: null;
|
||||
const createdAt = DateUtils.getFormattedShortDate(SnowflakeUtils.extractTimestamp(app.id));
|
||||
|
||||
return (
|
||||
<div key={app.id} className={styles.itemContainer}>
|
||||
<button type="button" className={styles.itemButton} onClick={() => onSelectApplication(app.id)}>
|
||||
<div className={styles.itemLeft}>
|
||||
{avatarUrl ? (
|
||||
<div className={styles.itemAvatar} style={{backgroundImage: `url(${avatarUrl})`}} aria-hidden />
|
||||
) : (
|
||||
<div className={styles.itemAvatarPlaceholder} aria-hidden>
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.itemTextBlock}>
|
||||
<div className={styles.itemTitleRow}>
|
||||
<span className={styles.itemName}>{app.name}</span>
|
||||
</div>
|
||||
<div className={styles.itemMetaRow}>
|
||||
<span>
|
||||
<Trans>Created {createdAt}</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CaretRightIcon className={styles.itemChevron} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,727 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.devControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.devControlButton {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.devControlButton[data-active='true'] {
|
||||
box-shadow: 0 0 0 2px var(--border-muted, var(--background-modifier-accent));
|
||||
}
|
||||
|
||||
.documentationLink {
|
||||
color: var(--text-link);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.documentationLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.documentationIcon {
|
||||
color: var(--text-link);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.modalButtons {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.errorCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-secondary);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.errorHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.errorSubtitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.errorActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
padding: var(--spacing-3) 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.itemContainer {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-textarea);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--input-container-min-height);
|
||||
padding: 0 var(--input-container-padding);
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.itemButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.itemButton:focus-visible {
|
||||
background-color: var(--background-modifier-hover);
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.itemLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemAvatar {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.itemAvatarPlaceholder {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--brand-experiment);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.itemTextBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMetaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 0.8125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemId {
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemChevron {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.itemChevron {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emptyStateDescription {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detailContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.headerPlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headerPlaceholder button {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-tertiary);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uriList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.uriItem {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--background-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.scopeList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--background-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.checkboxGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tokenBanner {
|
||||
padding: 1rem;
|
||||
background-color: var(--background-modifier-accent);
|
||||
border: 2px solid var(--status-warning);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tokenBannerHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tokenBannerTitle {
|
||||
margin: 0;
|
||||
color: var(--status-warning);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tokenBannerClose {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tokenBannerClose:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.tokenBannerText {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tokenDisplay {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tokenInput {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-tertiary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.botProfileSection {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.avatarSection {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.avatarDisplay {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarControls {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.botAvatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--background-modifier-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.botAvatarPlaceholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-experiment);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.botInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.avatarUploadContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
background-color: var(--background-tertiary);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileInput:hover {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.dangerZone {
|
||||
border-color: var(--status-danger);
|
||||
background-color: rgba(var(--status-danger-rgb), 0.05);
|
||||
}
|
||||
|
||||
.dangerZone .sectionHeader {
|
||||
border-bottom-color: var(--status-danger);
|
||||
}
|
||||
|
||||
.dangerZone .sectionTitle {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inputFooter {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-muted);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.fluxerTagContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.secretRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.secretLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.secretInputRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.secretActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redirectList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.redirectRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bannerSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.bannerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.permissionsGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.permissionsList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.permissionItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.builderResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.fluxerTagLabel {
|
||||
margin: 0;
|
||||
display: block;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fluxerTagInputRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.usernameInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
padding: 0 var(--spacing-1);
|
||||
}
|
||||
|
||||
.discriminatorInput {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.validationBox {
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-danger);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(var(--status-danger-rgb), 0.1);
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.spinnerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.scopeGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.scopeList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.scopeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* 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 {action, makeAutoObservable, runInAction} from 'mobx';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
import {DeveloperApplicationRecord} from '~/records/DeveloperApplicationRecord';
|
||||
|
||||
enum NavigationState {
|
||||
LOADING_LIST = 'LOADING_LIST',
|
||||
LIST = 'LIST',
|
||||
LOADING_DETAIL = 'LOADING_DETAIL',
|
||||
DETAIL = 'DETAIL',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
class ApplicationsTabStore {
|
||||
navigationState: NavigationState = NavigationState.LOADING_LIST;
|
||||
applicationOrder: Array<string> = [];
|
||||
applicationsById: Record<string, DeveloperApplicationRecord> = {};
|
||||
selectedAppId: string | null = null;
|
||||
error: string | null = null;
|
||||
isLoading: boolean = false;
|
||||
|
||||
private listAbortController: AbortController | null = null;
|
||||
private detailAbortController: AbortController | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
get contentKey(): string {
|
||||
if (this.navigationState === NavigationState.DETAIL && this.selectedAppId) {
|
||||
return `applications-detail-${this.selectedAppId}`;
|
||||
}
|
||||
return 'applications-main';
|
||||
}
|
||||
|
||||
get isDetailView(): boolean {
|
||||
return this.navigationState === NavigationState.DETAIL || this.navigationState === NavigationState.LOADING_DETAIL;
|
||||
}
|
||||
|
||||
get isListView(): boolean {
|
||||
return this.navigationState === NavigationState.LIST || this.navigationState === NavigationState.LOADING_LIST;
|
||||
}
|
||||
|
||||
get applications(): ReadonlyArray<DeveloperApplicationRecord> {
|
||||
const records: Array<DeveloperApplicationRecord> = [];
|
||||
for (const id of this.applicationOrder) {
|
||||
const record = this.applicationsById[id];
|
||||
if (record) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
get selectedApplication(): DeveloperApplicationRecord | null {
|
||||
if (!this.selectedAppId) {
|
||||
return null;
|
||||
}
|
||||
return this.applicationsById[this.selectedAppId] ?? null;
|
||||
}
|
||||
|
||||
get hasApplications(): boolean {
|
||||
return this.applicationOrder.length > 0;
|
||||
}
|
||||
|
||||
async fetchApplications(options?: {showLoading?: boolean}): Promise<void> {
|
||||
if (this.listAbortController) {
|
||||
this.listAbortController.abort();
|
||||
}
|
||||
|
||||
this.listAbortController = new AbortController();
|
||||
|
||||
const shouldShowLoading = options?.showLoading ?? (!this.hasApplications && !this.isDetailView);
|
||||
|
||||
runInAction(() => {
|
||||
if (shouldShowLoading) {
|
||||
this.navigationState = NavigationState.LOADING_LIST;
|
||||
}
|
||||
this.isLoading = shouldShowLoading;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await HttpClient.get<Array<DeveloperApplication>>({
|
||||
url: Endpoints.OAUTH_APPLICATIONS_LIST,
|
||||
signal: this.listAbortController.signal,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeApplications(response.body);
|
||||
if (!this.isDetailView) {
|
||||
this.navigationState = NavigationState.LIST;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as DOMException).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[ApplicationsTabStore] Failed to fetch applications:', err);
|
||||
|
||||
runInAction(() => {
|
||||
this.error = 'Failed to load applications';
|
||||
if (!this.isDetailView) {
|
||||
this.navigationState = NavigationState.ERROR;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.listAbortController = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchApplication(appId: string, options?: {showLoading?: boolean}): Promise<void> {
|
||||
if (this.detailAbortController) {
|
||||
this.detailAbortController.abort();
|
||||
}
|
||||
|
||||
this.detailAbortController = new AbortController();
|
||||
|
||||
const shouldShowLoading = Boolean(options?.showLoading);
|
||||
|
||||
runInAction(() => {
|
||||
this.isLoading = shouldShowLoading;
|
||||
if (shouldShowLoading) {
|
||||
this.navigationState = NavigationState.LOADING_DETAIL;
|
||||
}
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await HttpClient.get<DeveloperApplication>({
|
||||
url: Endpoints.OAUTH_APPLICATION(appId),
|
||||
signal: this.detailAbortController.signal,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.cacheApplication(response.body);
|
||||
this.navigationState = NavigationState.DETAIL;
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as DOMException).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[ApplicationsTabStore] Failed to fetch application:', err);
|
||||
runInAction(() => {
|
||||
this.error = 'Failed to load application details';
|
||||
this.navigationState = NavigationState.ERROR;
|
||||
});
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.detailAbortController = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToDetail(appId: string, initialApplication?: DeveloperApplication | null): Promise<void> {
|
||||
if (
|
||||
this.selectedAppId === appId &&
|
||||
(this.navigationState === NavigationState.DETAIL || this.navigationState === NavigationState.LOADING_DETAIL)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cacheHit = Boolean(this.applicationsById[appId]);
|
||||
if (initialApplication) {
|
||||
this.cacheApplication(initialApplication);
|
||||
cacheHit = true;
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.selectedAppId = appId;
|
||||
this.error = null;
|
||||
this.navigationState = cacheHit ? NavigationState.DETAIL : NavigationState.LOADING_DETAIL;
|
||||
});
|
||||
|
||||
await this.fetchApplication(appId, {showLoading: !cacheHit});
|
||||
}
|
||||
|
||||
async navigateToList(): Promise<void> {
|
||||
if (this.detailAbortController) {
|
||||
this.detailAbortController.abort();
|
||||
this.detailAbortController = null;
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.selectedAppId = null;
|
||||
this.error = null;
|
||||
if (this.hasApplications) {
|
||||
this.navigationState = NavigationState.LIST;
|
||||
} else {
|
||||
this.navigationState = NavigationState.LOADING_LIST;
|
||||
this.isLoading = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.hasApplications) {
|
||||
await this.fetchApplications({showLoading: true});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
clearError(): void {
|
||||
this.error = null;
|
||||
if (this.navigationState === NavigationState.ERROR) {
|
||||
if (this.isDetailView) {
|
||||
this.navigationState = NavigationState.LOADING_DETAIL;
|
||||
} else if (this.hasApplications) {
|
||||
this.navigationState = NavigationState.LIST;
|
||||
} else {
|
||||
this.navigationState = NavigationState.LOADING_LIST;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeApplications(applications: Array<DeveloperApplication>): void {
|
||||
const nextById: Record<string, DeveloperApplicationRecord> = {...this.applicationsById};
|
||||
const nextOrder: Array<string> = [];
|
||||
|
||||
for (const application of applications) {
|
||||
const record = DeveloperApplicationRecord.from(application);
|
||||
nextById[record.id] = record;
|
||||
nextOrder.push(record.id);
|
||||
}
|
||||
|
||||
this.applicationOrder = nextOrder;
|
||||
this.applicationsById = nextById;
|
||||
}
|
||||
|
||||
private cacheApplication(application: DeveloperApplication): DeveloperApplicationRecord {
|
||||
const record = DeveloperApplicationRecord.from(application);
|
||||
const nextById = {...this.applicationsById, [record.id]: record};
|
||||
let nextOrder: Array<string> = this.applicationOrder;
|
||||
|
||||
if (!nextOrder.includes(record.id)) {
|
||||
nextOrder = [...nextOrder, record.id];
|
||||
}
|
||||
|
||||
this.applicationsById = nextById;
|
||||
this.applicationOrder = nextOrder;
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApplicationsTabStore();
|
||||
@@ -0,0 +1,572 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-5) var(--spacing-4);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.breadcrumbRow {
|
||||
display: flex;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.heroTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heroTop > div {
|
||||
flex: 1;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin: 0 0 var(--spacing-2) 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 750;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.metaInput {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
gap: var(--spacing-6);
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.columnStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.builderSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionSpacer {
|
||||
height: var(--spacing-5);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cardDanger {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--spacing-2);
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 750;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cardSubtitle {
|
||||
margin: 0.35rem 0 0 0;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.secretRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.secretLabel {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.secretInputRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.secretActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fieldStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.toggleSwitch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toggleTitle {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toggleDescription {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.redirectList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.redirectRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redirectActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.redirectRow[data-first='true'] .redirectActions {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.redirectRow[data-first='true'] .redirectActions::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.redirectRemoveButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-primary-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s ease,
|
||||
background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.redirectRemoveButton:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.redirectRemoveButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addRedirectButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.scopeGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scopeList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.botPermissionList {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.botPermissionList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.botPermissionList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.scopeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0.35rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scopeLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.avatarRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.avatarPreview {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--brand-primary-rgb), 0.9),
|
||||
rgba(var(--brand-secondary-rgb, 95, 125, 255), 0.85)
|
||||
);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.tagRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: var(--spacing-2);
|
||||
row-gap: var(--spacing-2);
|
||||
align-items: end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.discriminatorInput {
|
||||
width: 6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.validationBox {
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.bannerRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.builderResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.loadingState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
padding: var(--spacing-5);
|
||||
}
|
||||
|
||||
.loadingCard,
|
||||
.errorCard {
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: var(--background-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinnerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.errorSubtitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.errorActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.helperText {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dangerContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dangerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dangerActions > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-danger);
|
||||
font-size: 0.95rem;
|
||||
padding: var(--spacing-2);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(var(--status-danger-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--status-danger-rgb), 0.35);
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowLeftIcon, CheckIcon, CopyIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
|
||||
interface ApplicationHeaderProps {
|
||||
name: string;
|
||||
applicationId: string;
|
||||
onBack: () => void;
|
||||
onCopyId: () => void;
|
||||
idCopied: boolean;
|
||||
}
|
||||
|
||||
export const ApplicationHeader: React.FC<ApplicationHeaderProps> = ({
|
||||
name,
|
||||
applicationId,
|
||||
onBack,
|
||||
onCopyId,
|
||||
idCopied,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<div className={styles.pageHeader}>
|
||||
<div className={styles.breadcrumbRow}>
|
||||
<Button variant="secondary" onClick={onBack} leftIcon={<ArrowLeftIcon size={16} weight="bold" />} fitContent>
|
||||
{t`Back to Applications`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCard}>
|
||||
<div className={styles.heroTop}>
|
||||
<div>
|
||||
<h2 className={styles.heroTitle}>{name}</h2>
|
||||
<Input
|
||||
label={t`Application ID`}
|
||||
value={applicationId}
|
||||
readOnly
|
||||
className={styles.metaInput}
|
||||
rightElement={
|
||||
<Button
|
||||
variant="secondary"
|
||||
compact
|
||||
fitContent
|
||||
onClick={onCopyId}
|
||||
leftIcon={idCopied ? <CheckIcon size={14} weight="bold" /> : <CopyIcon size={14} />}
|
||||
>
|
||||
{idCopied ? t`Copied` : t`Copy ID`}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {XIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
import {SectionCard} from './SectionCard';
|
||||
import type {ApplicationDetailForm} from './types';
|
||||
|
||||
interface ApplicationInfoSectionProps {
|
||||
form: ApplicationDetailForm;
|
||||
redirectInputs: Array<string>;
|
||||
onAddRedirect: () => void;
|
||||
onRemoveRedirect: (index: number) => void;
|
||||
onUpdateRedirect: (index: number, value: string) => void;
|
||||
}
|
||||
|
||||
export const ApplicationInfoSection: React.FC<ApplicationInfoSectionProps> = ({
|
||||
form,
|
||||
redirectInputs,
|
||||
onAddRedirect,
|
||||
onRemoveRedirect,
|
||||
onUpdateRedirect,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const redirectList = redirectInputs ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard title={t`Application information`} subtitle={t`Basic settings and allowed redirect URIs.`}>
|
||||
<div className={styles.fieldStack}>
|
||||
<Input
|
||||
{...form.register('name', {required: t`Application name is required`})}
|
||||
label={t`Application Name`}
|
||||
value={form.watch('name')}
|
||||
placeholder={t`My Application`}
|
||||
maxLength={100}
|
||||
error={form.formState.errors.name?.message}
|
||||
/>
|
||||
|
||||
<div className={styles.toggleRow}>
|
||||
<Switch
|
||||
label={t`Public bot`}
|
||||
description={t`Allow anyone to invite this bot to their communities.`}
|
||||
value={form.watch('botPublic')}
|
||||
onChange={(checked) => form.setValue('botPublic', checked, {shouldDirty: true})}
|
||||
className={styles.toggleSwitch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.redirectList}>
|
||||
{redirectList.map((value, idx) => (
|
||||
<div key={idx} className={styles.redirectRow} data-first={idx === 0 ? 'true' : undefined}>
|
||||
<Input
|
||||
label={idx === 0 ? t`Redirect URIs` : undefined}
|
||||
value={value}
|
||||
onChange={(e) => onUpdateRedirect(idx, e.target.value)}
|
||||
placeholder={t`https://example.com/callback`}
|
||||
/>
|
||||
<div className={styles.redirectActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.redirectRemoveButton}
|
||||
onClick={() => onRemoveRedirect(idx)}
|
||||
disabled={idx === 0}
|
||||
aria-label={t`Remove redirect URI`}
|
||||
>
|
||||
<XIcon size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="primary" fitContent className={styles.addRedirectButton} onClick={onAddRedirect}>
|
||||
{t`Add redirect`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Controller} from 'react-hook-form';
|
||||
import {Input, Textarea} from '~/components/form/Input';
|
||||
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
|
||||
import {AvatarUploader} from '~/components/modals/tabs/MyProfileTab/AvatarUploader';
|
||||
import {BannerUploader} from '~/components/modals/tabs/MyProfileTab/BannerUploader';
|
||||
import {ImagePreviewField} from '~/components/shared/ImagePreviewField';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
import {SectionCard} from './SectionCard';
|
||||
import type {ApplicationDetailForm} from './types';
|
||||
|
||||
interface BotProfileSectionProps {
|
||||
application: DeveloperApplication;
|
||||
form: ApplicationDetailForm;
|
||||
displayAvatarUrl: string | null;
|
||||
hasAvatar: boolean;
|
||||
hasClearedAvatar: boolean;
|
||||
displayBannerUrl: string | null;
|
||||
hasBanner: boolean;
|
||||
hasClearedBanner: boolean;
|
||||
isLifetimePremium: boolean;
|
||||
onAvatarChange: (value: string) => void;
|
||||
onAvatarClear: () => void;
|
||||
onBannerChange: (value: string) => void;
|
||||
onBannerClear: () => void;
|
||||
}
|
||||
|
||||
export const BotProfileSection: React.FC<BotProfileSectionProps> = ({
|
||||
application,
|
||||
form,
|
||||
displayAvatarUrl,
|
||||
hasAvatar,
|
||||
hasClearedAvatar,
|
||||
displayBannerUrl,
|
||||
hasBanner,
|
||||
hasClearedBanner,
|
||||
isLifetimePremium,
|
||||
onAvatarChange,
|
||||
onAvatarClear,
|
||||
onBannerChange,
|
||||
onBannerClear,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SectionCard title={t`Bot profile`} subtitle={t`Avatar, tag, and rich profile details for your bot.`}>
|
||||
<div className={styles.fieldStack}>
|
||||
<div className={styles.avatarRow}>
|
||||
{displayAvatarUrl ? (
|
||||
<img src={displayAvatarUrl} alt="Bot avatar" className={styles.avatarPreview} />
|
||||
) : (
|
||||
<div className={styles.avatarPlaceholder}>{application.bot?.username.charAt(0).toUpperCase()}</div>
|
||||
)}
|
||||
<AvatarUploader
|
||||
hasAvatar={hasAvatar && !hasClearedAvatar}
|
||||
onAvatarChange={onAvatarChange}
|
||||
onAvatarClear={onAvatarClear}
|
||||
hasPremium={true}
|
||||
isPerGuildProfile={false}
|
||||
errorMessage={form.formState.errors.avatar?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tagRow}>
|
||||
<Controller
|
||||
name="username"
|
||||
control={form.control}
|
||||
rules={{
|
||||
required: t`Username is required`,
|
||||
minLength: {value: 1, message: t`Username must be at least 1 character`},
|
||||
maxLength: {value: 32, message: t`Username must be at most 32 characters`},
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9_]+$/,
|
||||
message: t`Username can only contain letters, numbers, and underscores`,
|
||||
},
|
||||
}}
|
||||
render={({field}) => (
|
||||
<Input
|
||||
{...field}
|
||||
aria-label={t`Bot Username`}
|
||||
placeholder={t`BotName`}
|
||||
maxLength={32}
|
||||
required
|
||||
label={t`FluxerTag`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.discriminatorInput}>
|
||||
{isLifetimePremium ? (
|
||||
<Controller
|
||||
name="discriminator"
|
||||
control={form.control}
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^\d{1,4}$/,
|
||||
message: t`Discriminator must be 1-4 digits`,
|
||||
},
|
||||
validate: (value) => {
|
||||
if (!value) return true;
|
||||
const num = parseInt(value, 10);
|
||||
if (num < 0 || num > 9999) {
|
||||
return t`Discriminator must be between 0 and 9999`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({field}) => (
|
||||
<Input {...field} aria-label={t`Discriminator`} placeholder="0000" maxLength={4} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={application.bot?.discriminator}
|
||||
readOnly
|
||||
disabled
|
||||
maxLength={4}
|
||||
aria-label={t`Discriminator`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.formState.errors.username || form.formState.errors.discriminator) && (
|
||||
<div className={styles.error}>
|
||||
{form.formState.errors.username?.message || form.formState.errors.discriminator?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.validationBox}>
|
||||
<UsernameValidationRules username={form.watch('username') || ''} />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
{...form.register('bio')}
|
||||
label={t`Bot Bio`}
|
||||
value={form.watch('bio') || ''}
|
||||
placeholder={t`A helpful bot that does amazing things!`}
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
maxLength={1024}
|
||||
error={form.formState.errors.bio?.message}
|
||||
/>
|
||||
|
||||
<div className={styles.bannerRow}>
|
||||
<BannerUploader
|
||||
hasBanner={hasBanner}
|
||||
onBannerChange={onBannerChange}
|
||||
onBannerClear={onBannerClear}
|
||||
hasPremium={true}
|
||||
isPerGuildProfile={false}
|
||||
errorMessage={form.formState.errors.banner?.message as string | undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImagePreviewField
|
||||
imageUrl={hasBanner && !hasClearedBanner ? displayBannerUrl : null}
|
||||
showPlaceholder={!hasBanner || hasClearedBanner}
|
||||
placeholderText={t`No bot banner`}
|
||||
altText={t`Bot banner preview`}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {t} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {CopyIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {Controller} from 'react-hook-form';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Select, type SelectOption} from '~/components/form/Select';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
import {SectionCard} from './SectionCard';
|
||||
import type {ApplicationDetailForm} from './types';
|
||||
|
||||
interface OAuthBuilderSectionProps {
|
||||
form: ApplicationDetailForm;
|
||||
availableScopes: ReadonlyArray<string>;
|
||||
builderScopeList: Array<string>;
|
||||
botPermissionsList: Array<{id: string; label: string}>;
|
||||
builderUrl: string;
|
||||
redirectOptions: Array<SelectOption<string>>;
|
||||
onCopyBuilderUrl: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const OAuthBuilderSection: React.FC<OAuthBuilderSectionProps> = ({
|
||||
form,
|
||||
availableScopes,
|
||||
builderScopeList,
|
||||
botPermissionsList,
|
||||
builderUrl,
|
||||
redirectOptions,
|
||||
onCopyBuilderUrl,
|
||||
}) => {
|
||||
const builderRedirectUri = form.watch('builderRedirectUri');
|
||||
|
||||
const redirectError =
|
||||
!builderScopeList.includes('bot') && !builderRedirectUri
|
||||
? t`Redirect URI is required when not using only the bot scope.`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={<Trans>OAuth2 URL Builder</Trans>}
|
||||
subtitle={<Trans>Construct an authorize URL with scopes and permissions.</Trans>}
|
||||
>
|
||||
<div className={styles.fieldStack}>
|
||||
<div className={styles.scopeGrid}>
|
||||
<div className={styles.fieldLabel}>
|
||||
<Trans>Scopes</Trans>
|
||||
</div>
|
||||
<div className={styles.scopeList}>
|
||||
{availableScopes.map((scope) => (
|
||||
<div key={scope} className={styles.scopeItem}>
|
||||
<Controller
|
||||
name={`builderScopes.${scope}` as const}
|
||||
control={form.control}
|
||||
render={({field}) => (
|
||||
<Checkbox checked={!!field.value} onChange={(checked) => field.onChange(checked)} size="small">
|
||||
<span className={styles.scopeLabel}>{scope}</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="builderRedirectUri"
|
||||
control={form.control}
|
||||
render={({field}) => (
|
||||
<Select
|
||||
label={t`Redirect URI (required unless only bot scope)`}
|
||||
placeholder={t`Select a redirect URI`}
|
||||
value={field.value ?? ''}
|
||||
options={redirectOptions}
|
||||
onChange={(val) => field.onChange(val || '')}
|
||||
isClearable
|
||||
error={redirectError}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{builderScopeList.includes('bot') && (
|
||||
<div className={styles.scopeGrid}>
|
||||
<div className={styles.fieldLabel}>
|
||||
<Trans>Bot permissions</Trans>
|
||||
</div>
|
||||
<div className={`${styles.scopeList} ${styles.botPermissionList}`}>
|
||||
{botPermissionsList.map((perm) => (
|
||||
<div key={perm.id} className={styles.scopeItem}>
|
||||
<Controller
|
||||
name={`builderPermissions.${perm.id}` as const}
|
||||
control={form.control}
|
||||
render={({field}) => (
|
||||
<Checkbox checked={!!field.value} onChange={(checked) => field.onChange(checked)} size="small">
|
||||
<span className={styles.scopeLabel}>{perm.label}</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.builderResult}>
|
||||
<Input
|
||||
label={t`Authorize URL`}
|
||||
value={builderUrl}
|
||||
readOnly
|
||||
placeholder={t`Select scopes (and redirect URI if required)`}
|
||||
rightElement={
|
||||
<Button
|
||||
variant="primary"
|
||||
compact
|
||||
fitContent
|
||||
aria-label={t`Copy authorize URL`}
|
||||
leftIcon={<CopyIcon size={16} />}
|
||||
disabled={!builderUrl}
|
||||
onClick={onCopyBuilderUrl}
|
||||
>
|
||||
<span className={styles.srOnly}>
|
||||
<Trans>Copy</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!builderUrl && (
|
||||
<div className={styles.error}>
|
||||
<Trans>Select scopes and a redirect URI (unless bot-only) to generate a URL.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
import {SectionCard} from './SectionCard';
|
||||
|
||||
interface SecretsSectionProps {
|
||||
clientSecret: string | null;
|
||||
botToken: string | null;
|
||||
onRegenerateClientSecret: () => void;
|
||||
onRegenerateBotToken: () => void;
|
||||
isRotatingClient: boolean;
|
||||
isRotatingBot: boolean;
|
||||
hasBot: boolean;
|
||||
clientSecretInputId: string;
|
||||
botTokenInputId: string;
|
||||
}
|
||||
|
||||
export const SecretsSection: React.FC<SecretsSectionProps> = ({
|
||||
clientSecret,
|
||||
botToken,
|
||||
onRegenerateClientSecret,
|
||||
onRegenerateBotToken,
|
||||
isRotatingClient,
|
||||
isRotatingBot,
|
||||
hasBot,
|
||||
clientSecretInputId,
|
||||
botTokenInputId,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SectionCard
|
||||
title={t`Secrets & tokens`}
|
||||
subtitle={t`Keep these safe. Regenerating will break existing integrations.`}
|
||||
>
|
||||
<div className={styles.fieldStack}>
|
||||
<div className={styles.secretRow}>
|
||||
<Input
|
||||
id={clientSecretInputId}
|
||||
label={t`Client secret`}
|
||||
type="text"
|
||||
value={clientSecret ?? ''}
|
||||
readOnly
|
||||
placeholder={clientSecret ? '•'.repeat(64) : '•'.repeat(64)}
|
||||
/>
|
||||
<div className={styles.secretActions}>
|
||||
<Button variant="primary" compact submitting={isRotatingClient} onClick={onRegenerateClientSecret}>
|
||||
{t`Regenerate`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBot && (
|
||||
<div className={styles.secretRow}>
|
||||
<Input
|
||||
id={botTokenInputId}
|
||||
label={t`Bot token`}
|
||||
type="text"
|
||||
value={botToken ?? ''}
|
||||
readOnly
|
||||
placeholder={botToken ? '•'.repeat(64) : '•'.repeat(64)}
|
||||
/>
|
||||
<div className={styles.secretActions}>
|
||||
<Button variant="primary" compact submitting={isRotatingBot} onClick={onRegenerateBotToken}>
|
||||
{t`Regenerate`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 clsx from 'clsx';
|
||||
import type React from 'react';
|
||||
import styles from './ApplicationDetail.module.css';
|
||||
|
||||
interface SectionCardProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
tone?: 'default' | 'danger';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SectionCard: React.FC<SectionCardProps> = ({title, subtitle, actions, children, tone = 'default'}) => {
|
||||
return (
|
||||
<section className={clsx(styles.card, tone === 'danger' && styles.cardDanger)}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div>
|
||||
<h3 className={styles.cardTitle}>{title}</h3>
|
||||
{subtitle && <p className={styles.cardSubtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className={styles.cardActions}>{actions}</div>}
|
||||
</div>
|
||||
<div className={styles.cardBody}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 type {UseFormReturn} from 'react-hook-form';
|
||||
|
||||
export interface ApplicationDetailFormValues {
|
||||
name: string;
|
||||
redirectUris: Array<string>;
|
||||
botPublic: boolean;
|
||||
username?: string;
|
||||
discriminator?: string;
|
||||
avatar?: string | null;
|
||||
bio?: string | null;
|
||||
banner?: string | null;
|
||||
redirectUriInputs: Array<string>;
|
||||
builderScopes: Record<string, boolean>;
|
||||
builderRedirectUri?: string;
|
||||
builderPermissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export type ApplicationDetailForm = UseFormReturn<ApplicationDetailFormValues>;
|
||||
156
fluxer_app/src/components/modals/tabs/ApplicationsTab/index.tsx
Normal file
156
fluxer_app/src/components/modals/tabs/ApplicationsTab/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
|
||||
import {useUnsavedChangesFlash} from '~/components/modals/hooks/useUnsavedChangesFlash';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {ApplicationCreateModal} from '~/components/modals/tabs/ApplicationsTab/ApplicationCreateModal';
|
||||
import {ApplicationDetail} from '~/components/modals/tabs/ApplicationsTab/ApplicationDetail';
|
||||
import {ApplicationsList} from '~/components/modals/tabs/ApplicationsTab/ApplicationsList';
|
||||
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
|
||||
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
|
||||
const ApplicationsTab: React.FC = observer(() => {
|
||||
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
|
||||
const {setContentKey} = useSettingsContentKey();
|
||||
const store = ApplicationsTabStore;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setContentKey(store.contentKey);
|
||||
}, [store.contentKey, setContentKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void store.fetchApplications({showLoading: store.applications.length === 0});
|
||||
}, [store]);
|
||||
|
||||
const handleSelectApplication = React.useCallback(
|
||||
(appId: string) => {
|
||||
if (checkUnsavedChanges()) return;
|
||||
void store.navigateToDetail(appId);
|
||||
},
|
||||
[store, checkUnsavedChanges],
|
||||
);
|
||||
|
||||
const openCreateModal = React.useCallback(() => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ApplicationCreateModal
|
||||
onCreated={async (app: DeveloperApplication) => {
|
||||
await store.navigateToDetail(app.id, app);
|
||||
void store.fetchApplications({showLoading: false});
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [store]);
|
||||
|
||||
const handleBackToList = React.useCallback(() => {
|
||||
if (checkUnsavedChanges()) return;
|
||||
void store.navigateToList();
|
||||
}, [store, checkUnsavedChanges]);
|
||||
|
||||
if (store.navigationState === 'LOADING_LIST' || (store.isLoading && store.isListView)) {
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<div className={styles.spinnerContainer}>
|
||||
<Spinner />
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (store.navigationState === 'ERROR' && store.isListView) {
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Applications & Bots</Trans>}
|
||||
description={<Trans>Manage your applications and bots.</Trans>}
|
||||
>
|
||||
<StatusSlate
|
||||
Icon={WarningCircleIcon}
|
||||
title={<Trans>Unable to load applications</Trans>}
|
||||
description={<Trans>Check your connection and try again.</Trans>}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Retry</Trans>,
|
||||
onClick: () => store.fetchApplications({showLoading: true}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (store.isDetailView && store.selectedAppId) {
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<ApplicationDetail
|
||||
applicationId={store.selectedAppId}
|
||||
onBack={handleBackToList}
|
||||
initialApplication={store.selectedApplication}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Applications & Bots</Trans>}
|
||||
description={<Trans>Create and manage applications and bots for your account.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
|
||||
<Trans>Create Application</Trans>
|
||||
</Button>
|
||||
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
|
||||
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
|
||||
<Trans>Read the Documentation (fluxer.dev)</Trans>
|
||||
</a>
|
||||
</div>
|
||||
<ApplicationsList applications={store.applications} onSelectApplication={handleSelectApplication} />
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default ApplicationsTab;
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollerPadding {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.appList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.appCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-textarea);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--input-container-min-height);
|
||||
padding: 0 var(--input-container-padding);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.headerButton:focus-visible {
|
||||
background-color: var(--background-modifier-hover);
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.appAvatar {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appAvatarImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.appAvatarPlaceholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.textBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 0.875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.chevronExpanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.details {
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
padding: var(--spacing-3) var(--input-container-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.detailsRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.detailsRow {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.scopeColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.scopeList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.scopeTag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: var(--spacing-2);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.scopeName {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.scopeDescription {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.actions > :global(button) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
239
fluxer_app/src/components/modals/tabs/AuthorizedAppsTab.tsx
Normal file
239
fluxer_app/src/components/modals/tabs/AuthorizedAppsTab.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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 {t} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {AppWindowIcon, CaretDownIcon, NetworkSlashIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import type {OAuth2Authorization} from '~/actions/OAuth2AuthorizationActionCreators';
|
||||
import * as OAuth2AuthorizationActionCreators from '~/actions/OAuth2AuthorizationActionCreators';
|
||||
import {getOAuth2ScopeDescription, type OAuth2Scope} from '~/Constants';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {getUserAvatarURL} from '~/utils/AvatarUtils';
|
||||
import styles from './AuthorizedAppsTab.module.css';
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const AuthorizedAppsTab = observer(function AuthorizedAppsTab() {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const [authorizations, setAuthorizations] = React.useState<Array<OAuth2Authorization>>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpanded = React.useCallback((appId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(appId)) {
|
||||
next.delete(appId);
|
||||
} else {
|
||||
next.add(appId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadAuthorizations = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await OAuth2AuthorizationActionCreators.listAuthorizations();
|
||||
setAuthorizations(data);
|
||||
} catch (_err) {
|
||||
setError(t`Failed to load authorized applications`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadAuthorizations();
|
||||
}, [loadAuthorizations]);
|
||||
|
||||
const handleDeauthorize = React.useCallback((authorization: OAuth2Authorization) => {
|
||||
const appName = authorization.application.name;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Deauthorize Application`}
|
||||
description={t`Are you sure you want to deauthorize ${appName}? This application will no longer have access to your account.`}
|
||||
primaryText={t`Deauthorize`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
await OAuth2AuthorizationActionCreators.deauthorize(authorization.application.id);
|
||||
setAuthorizations((prev) => prev.filter((a) => a.application.id !== authorization.application.id));
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={NetworkSlashIcon}
|
||||
title={t`Failed to load authorized applications`}
|
||||
description={error}
|
||||
actions={[
|
||||
{
|
||||
text: t`Retry`,
|
||||
onClick: loadAuthorizations,
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (authorizations.length === 0) {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={AppWindowIcon}
|
||||
title={<Trans>No Authorized Applications</Trans>}
|
||||
description={<Trans>You haven't authorized any applications to access your account.</Trans>}
|
||||
fullHeight
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>
|
||||
<Trans>Authorized Applications</Trans>
|
||||
</h2>
|
||||
<p className={styles.description}>
|
||||
<Trans>These applications have been granted access to your Fluxer account.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.scrollContainer}>
|
||||
<Scroller className={styles.scrollerPadding}>
|
||||
<div className={styles.appList}>
|
||||
{authorizations.map((authorization) => {
|
||||
const iconUrl = authorization.application.icon
|
||||
? getUserAvatarURL({
|
||||
id: authorization.application.id,
|
||||
avatar: authorization.application.icon,
|
||||
})
|
||||
: null;
|
||||
|
||||
const isExpanded = expandedIds.has(authorization.application.id);
|
||||
const authorizedOn = formatDate(authorization.authorized_at);
|
||||
|
||||
return (
|
||||
<div key={authorization.application.id} className={styles.appCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
onClick={() => toggleExpanded(authorization.application.id)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className={styles.left}>
|
||||
<div className={styles.appAvatar} aria-hidden>
|
||||
{iconUrl ? (
|
||||
<img src={iconUrl} alt={authorization.application.name} className={styles.appAvatarImage} />
|
||||
) : (
|
||||
<AppWindowIcon className={styles.appAvatarPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.textBlock}>
|
||||
<div className={styles.titleRow}>
|
||||
<span className={styles.appName}>{authorization.application.name}</span>
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaText}>
|
||||
<Trans>Authorized on {authorizedOn}</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CaretDownIcon
|
||||
weight="bold"
|
||||
className={clsx(styles.chevron, isExpanded && styles.chevronExpanded)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.detailsRow}>
|
||||
<div className={styles.scopeColumn}>
|
||||
<div className={styles.sectionLabel}>
|
||||
<Trans>Permissions granted</Trans>
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeList}>
|
||||
{authorization.scopes.map((scope) => (
|
||||
<div key={scope} className={styles.scopeTag}>
|
||||
<span className={styles.scopeName}>{scope}</span>
|
||||
<span className={styles.scopeDescription}>
|
||||
{getOAuth2ScopeDescription(i18n, scope as OAuth2Scope) || scope}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button variant="danger-primary" small onClick={() => handleDeauthorize(authorization)}>
|
||||
<Trans>Deauthorize</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AuthorizedAppsTab;
|
||||
325
fluxer_app/src/components/modals/tabs/BetaCodesTab.module.css
Normal file
325
fluxer_app/src/components/modals/tabs/BetaCodesTab.module.css
Normal file
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.spinnerContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.allowanceText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.subsection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.subsectionTitle {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listItems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: none;
|
||||
grid-template-columns: 200px minmax(150px, 1fr) 130px;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.listHeader {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.listHeaderColumn {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.listItemMobile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.listItemMobile:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.codeRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.labelMobile {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.labelMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.code {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.redeemerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.redeemerInfo {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.redeemerName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.redeemerDate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.unclaimedIcon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.unclaimedIconSvg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.unclaimedText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.createdRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.createdLabel {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.createdLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.createdDate {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.revokeButton {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-primary);
|
||||
padding: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition-property: border-color, background-color, color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.revokeButton:hover {
|
||||
border-color: var(--status-danger);
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.revokeButtonIcon {
|
||||
height: 0.75rem;
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.listItemDesktop {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(150px, 1fr) 130px;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
transition-property: background-color, color, opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background-color: var(--background-header-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copyButtonHidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.copyButtonVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copyButtonIcon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.revokeButtonDesktop {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-primary);
|
||||
padding: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition-property: border-color, background-color, color, opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.revokeButtonDesktop:hover {
|
||||
border-color: var(--status-danger);
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.revokeButtonDesktopHidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.revokeButtonDesktopHidden {
|
||||
opacity: 0;
|
||||
}
|
||||
.listItemDesktop:hover .revokeButtonDesktopHidden {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.revokeButtonDesktopVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatarNoShrink {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
382
fluxer_app/src/components/modals/tabs/BetaCodesTab.tsx
Normal file
382
fluxer_app/src/components/modals/tabs/BetaCodesTab.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ClipboardIcon, QuestionMarkIcon, TicketIcon, XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabHeader,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {Avatar} from '~/components/uikit/Avatar';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
|
||||
import BetaCodeStore from '~/stores/BetaCodeStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import * as DateUtils from '~/utils/DateUtils';
|
||||
import styles from './BetaCodesTab.module.css';
|
||||
|
||||
const MAX_UNCLAIMED_BETA_CODES = 6;
|
||||
|
||||
const BetaCodeListHeader: React.FC = observer(() => {
|
||||
return (
|
||||
<div className={styles.listHeader}>
|
||||
<div className={styles.listHeaderColumn}>
|
||||
<Trans>Code</Trans>
|
||||
</div>
|
||||
<div className={styles.listHeaderColumn}>
|
||||
<Trans>Redeemer</Trans>
|
||||
</div>
|
||||
<div className={styles.listHeaderColumn}>
|
||||
<Trans>Created</Trans>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BetaCodeListItem: React.FC<{
|
||||
betaCode: BetaCodeRecord;
|
||||
onRevoke: (code: string) => void;
|
||||
}> = observer(({betaCode, onRevoke}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {enabled: isMobile} = MobileLayoutStore;
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
TextCopyActionCreators.copy(i18n, betaCode.code);
|
||||
};
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (isMobile) {
|
||||
TextCopyActionCreators.copy(i18n, betaCode.code);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (isMobile && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
};
|
||||
|
||||
const createdDate = DateUtils.getFormattedShortDate(betaCode.createdAt);
|
||||
const createdTooltip = DateUtils.getFormattedDateTimeWithSeconds(betaCode.createdAt);
|
||||
|
||||
return isMobile ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={styles.listItemMobile}
|
||||
>
|
||||
<div className={styles.codeRow}>
|
||||
<span className={styles.labelMobile}>
|
||||
<Trans>Code:</Trans>
|
||||
</span>
|
||||
<code className={styles.code}>{betaCode.code}</code>
|
||||
</div>
|
||||
|
||||
<div className={styles.redeemerRow}>
|
||||
<span className={styles.labelMobile}>
|
||||
<Trans>Redeemer:</Trans>
|
||||
</span>
|
||||
{betaCode.redeemer ? (
|
||||
<>
|
||||
<Avatar user={betaCode.redeemer} size={32} className={styles.avatarNoShrink} />
|
||||
<div className={styles.redeemerInfo}>
|
||||
<span className={styles.redeemerName}>{betaCode.redeemer.tag}</span>
|
||||
<span className={styles.redeemerDate}>{DateUtils.getFormattedShortDate(betaCode.redeemedAt!)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.unclaimedIcon} style={{width: 32, height: 32}}>
|
||||
<QuestionMarkIcon className={styles.unclaimedIconSvg} weight="bold" />
|
||||
</div>
|
||||
<span className={styles.unclaimedText}>
|
||||
<Trans>Unclaimed</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.createdRow}>
|
||||
<span className={styles.createdLabel}>
|
||||
<Trans>Created:</Trans>
|
||||
</span>
|
||||
<Tooltip text={createdTooltip}>
|
||||
<span className={styles.createdDate}>{createdDate}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!betaCode.redeemer && (
|
||||
<Tooltip text={i18n._(msg`Revoke`)}>
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevoke(betaCode.code);
|
||||
}}
|
||||
className={styles.revokeButton}
|
||||
>
|
||||
<XIcon className={styles.revokeButtonIcon} weight="bold" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Hover state is for visual feedback only, not for interaction
|
||||
<div
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={styles.listItemDesktop}
|
||||
>
|
||||
<div className={styles.codeRow}>
|
||||
<span className={styles.labelMobile}>
|
||||
<Trans>Code:</Trans>
|
||||
</span>
|
||||
<code className={styles.code}>{betaCode.code}</code>
|
||||
|
||||
<Tooltip text={i18n._(msg`Click to copy`)}>
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={clsx(styles.copyButton, isHovered ? styles.copyButtonVisible : styles.copyButtonHidden)}
|
||||
aria-label={i18n._(msg`Copy beta code`)}
|
||||
>
|
||||
<ClipboardIcon className={styles.copyButtonIcon} />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.redeemerRow}>
|
||||
<span className={styles.labelMobile}>
|
||||
<Trans>Redeemer:</Trans>
|
||||
</span>
|
||||
{betaCode.redeemer ? (
|
||||
<>
|
||||
<Avatar user={betaCode.redeemer} size={32} className={styles.avatarNoShrink} />
|
||||
<div className={styles.redeemerInfo}>
|
||||
<span className={styles.redeemerName}>{betaCode.redeemer.tag}</span>
|
||||
<span className={styles.redeemerDate}>{DateUtils.getFormattedShortDate(betaCode.redeemedAt!)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.unclaimedIcon} style={{width: 32, height: 32}}>
|
||||
<QuestionMarkIcon className={styles.unclaimedIconSvg} weight="bold" />
|
||||
</div>
|
||||
<span className={styles.unclaimedText}>
|
||||
<Trans>Unclaimed</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.createdRow}>
|
||||
<span className={styles.createdLabel}>
|
||||
<Trans>Created:</Trans>
|
||||
</span>
|
||||
<Tooltip text={createdTooltip}>
|
||||
<span className={styles.createdDate}>{createdDate}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!betaCode.redeemer && (
|
||||
<Tooltip text={i18n._(msg`Revoke`)}>
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevoke(betaCode.code);
|
||||
}}
|
||||
className={clsx(
|
||||
styles.revokeButtonDesktop,
|
||||
isHovered ? styles.revokeButtonDesktopVisible : styles.revokeButtonDesktopHidden,
|
||||
)}
|
||||
>
|
||||
<XIcon className={styles.revokeButtonIcon} weight="bold" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BetaCodeListItem.displayName = 'BetaCodeListItem';
|
||||
|
||||
const BetaCodesTab: React.FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const betaCodes = BetaCodeStore.betaCodes;
|
||||
const fetchStatus = BetaCodeStore.fetchStatus;
|
||||
const allowance = BetaCodeStore.allowance;
|
||||
const nextResetAt = BetaCodeStore.nextResetAt;
|
||||
|
||||
React.useEffect(() => {
|
||||
BetaCodeActionCreators.fetch();
|
||||
}, []);
|
||||
|
||||
const sortedBetaCodes = React.useMemo(() => {
|
||||
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}, [betaCodes]);
|
||||
|
||||
const unclaimedBetaCodes = sortedBetaCodes.filter((code) => !code.redeemer);
|
||||
const redeemedBetaCodes = sortedBetaCodes.filter((code) => code.redeemer);
|
||||
|
||||
const canGenerateMore = unclaimedBetaCodes.length < MAX_UNCLAIMED_BETA_CODES && allowance > 0;
|
||||
|
||||
const handleRevoke = React.useCallback((code: string) => {
|
||||
BetaCodeActionCreators.remove(code);
|
||||
}, []);
|
||||
|
||||
const handleCreate = React.useCallback(() => {
|
||||
BetaCodeActionCreators.create();
|
||||
}, []);
|
||||
|
||||
const allowanceText = React.useMemo(() => {
|
||||
if (allowance === 0 && nextResetAt !== null) {
|
||||
const resetString = DateUtils.getFormattedShortDate(nextResetAt);
|
||||
return i18n._(msg`No codes remaining. Resets ${resetString}`);
|
||||
}
|
||||
if (allowance === 1) {
|
||||
return i18n._(msg`1 code remaining this week`);
|
||||
}
|
||||
return i18n._(msg`${allowance} codes remaining this week`);
|
||||
}, [allowance, nextResetAt, i18n]);
|
||||
|
||||
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
|
||||
return (
|
||||
<div className={styles.spinnerContainer}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchStatus === 'error') {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={TicketIcon}
|
||||
title={i18n._(msg`Network error`)}
|
||||
description={i18n._(
|
||||
msg`We're having trouble connecting to the space-time continuum. Please check your connection and try again.`,
|
||||
)}
|
||||
actions={[
|
||||
{
|
||||
text: i18n._(msg`Retry`),
|
||||
onClick: () => BetaCodeActionCreators.fetch(),
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabHeader
|
||||
title={<Trans>Beta Codes</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Generate up to {MAX_UNCLAIMED_BETA_CODES} unclaimed beta codes to invite friends to Fluxer. You can create 3
|
||||
codes per week.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsTabContent>
|
||||
<div className={styles.actionRow}>
|
||||
<div className={styles.allowanceText}>{allowanceText}</div>
|
||||
<Button small={true} disabled={!canGenerateMore} onClick={handleCreate}>
|
||||
<Trans>Create Code</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{unclaimedBetaCodes.length > 0 && (
|
||||
<div className={styles.subsection}>
|
||||
<div className={styles.subsectionTitle}>
|
||||
<Trans>Unclaimed ({unclaimedBetaCodes.length})</Trans>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<BetaCodeListHeader />
|
||||
<div className={styles.listItems}>
|
||||
{unclaimedBetaCodes.map((betaCode) => (
|
||||
<BetaCodeListItem key={betaCode.code} betaCode={betaCode} onRevoke={handleRevoke} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redeemedBetaCodes.length > 0 && (
|
||||
<div className={styles.subsection}>
|
||||
<div className={styles.subsectionTitle}>
|
||||
<Trans>Redeemed ({redeemedBetaCodes.length})</Trans>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<BetaCodeListHeader />
|
||||
<div className={styles.listItems}>
|
||||
{redeemedBetaCodes.map((betaCode) => (
|
||||
<BetaCodeListItem key={betaCode.code} betaCode={betaCode} onRevoke={handleRevoke} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{betaCodes.length === 0 && (
|
||||
<StatusSlate
|
||||
Icon={TicketIcon}
|
||||
title={i18n._(msg`No beta codes yet`)}
|
||||
description={i18n._(msg`Click "Create Code" to generate your first code.`)}
|
||||
actions={[
|
||||
{
|
||||
text: i18n._(msg`Create Code`),
|
||||
onClick: handleCreate,
|
||||
variant: canGenerateMore ? 'primary' : 'secondary',
|
||||
fitContent: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default BetaCodesTab;
|
||||
126
fluxer_app/src/components/modals/tabs/BlockedUsersTab.module.css
Normal file
126
fluxer_app/src/components/modals/tabs/BlockedUsersTab.module.css
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollerPadding {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.userList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.userCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.usernameButton {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.usernameContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: inline;
|
||||
align-items: baseline;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.discriminator {
|
||||
display: inline;
|
||||
align-items: baseline;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.moreIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
192
fluxer_app/src/components/modals/tabs/BlockedUsersTab.tsx
Normal file
192
fluxer_app/src/components/modals/tabs/BlockedUsersTab.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 {CopyIcon, DotsThreeVerticalIcon, IdentificationCardIcon, ProhibitIcon, UserIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import styles from '~/components/modals/tabs/BlockedUsersTab.module.css';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
const BlockedUsersTab: React.FC = observer(() => {
|
||||
const {t, i18n} = useLingui();
|
||||
const relationships = RelationshipStore.getRelationships();
|
||||
const blockedUsers = React.useMemo(() => {
|
||||
return relationships.filter((rel) => rel.type === RelationshipTypes.BLOCKED);
|
||||
}, [relationships]);
|
||||
|
||||
const handleUnblockUser = (userId: string) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Unblock User`}
|
||||
description={t`Are you sure you want to unblock ${user.username}?`}
|
||||
primaryText={t`Unblock`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={async () => {
|
||||
RelationshipActionCreators.removeRelationship(userId);
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewProfile = React.useCallback((userId: string) => {
|
||||
UserProfileActionCreators.openUserProfile(userId);
|
||||
}, []);
|
||||
|
||||
const handleMoreOptionsClick = React.useCallback(
|
||||
(userId: string, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return;
|
||||
|
||||
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItem
|
||||
icon={<UserIcon size={16} />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
handleViewProfile(userId);
|
||||
}}
|
||||
>
|
||||
{t`View Profile`}
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
<MenuGroup>
|
||||
<MenuItem
|
||||
icon={<CopyIcon size={16} />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
TextCopyActionCreators.copy(i18n, user.tag, true);
|
||||
}}
|
||||
>
|
||||
{t`Copy FluxerTag`}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IdentificationCardIcon size={16} />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
TextCopyActionCreators.copy(i18n, user.id, true);
|
||||
}}
|
||||
>
|
||||
{t`Copy User ID`}
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
</>
|
||||
));
|
||||
},
|
||||
[handleViewProfile],
|
||||
);
|
||||
|
||||
if (blockedUsers.length === 0) {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={ProhibitIcon}
|
||||
title={<Trans>No Blocked Users</Trans>}
|
||||
description={<Trans>You haven't blocked anyone yet.</Trans>}
|
||||
fullHeight={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>
|
||||
<Trans>Blocked Users</Trans>
|
||||
</h2>
|
||||
<p className={styles.description}>
|
||||
<Trans>Blocked users can't send you friend requests or message you directly.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.scrollContainer}>
|
||||
<Scroller className={styles.scrollerPadding} key="blocked-users-scroller">
|
||||
<div className={styles.userList}>
|
||||
{blockedUsers.map((relationship) => {
|
||||
const user = UserStore.getUser(relationship.id);
|
||||
if (!user) return null;
|
||||
|
||||
const moreOptionsButtonRef = React.createRef<HTMLButtonElement>();
|
||||
|
||||
return (
|
||||
<div key={user.id} className={styles.userCard}>
|
||||
<div className={styles.userInfo}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.avatarButton}
|
||||
onClick={() => handleViewProfile(user.id)}
|
||||
aria-label={`View ${user.username}'s profile`}
|
||||
>
|
||||
<StatusAwareAvatar user={user} size={40} disablePresence={true} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.usernameButton}
|
||||
onClick={() => handleViewProfile(user.id)}
|
||||
aria-label={`View ${user.username}'s profile`}
|
||||
>
|
||||
<div className={styles.usernameContainer}>
|
||||
<span className={styles.username}>{user.username}</span>
|
||||
<span className={styles.discriminator}>#{user.discriminator}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="secondary" small={true} onClick={() => handleUnblockUser(user.id)}>
|
||||
<Trans>Unblock</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
ref={moreOptionsButtonRef}
|
||||
variant="secondary"
|
||||
small={true}
|
||||
square={true}
|
||||
icon={<DotsThreeVerticalIcon weight="bold" className={styles.moreIcon} />}
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => handleMoreOptionsClick(user.id, event)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BlockedUsersTab;
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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: var(--spacing-6);
|
||||
}
|
||||
65
fluxer_app/src/components/modals/tabs/ChatSettingsTab.tsx
Normal file
65
fluxer_app/src/components/modals/tabs/ChatSettingsTab.tsx
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/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {DisplayTabContent} from './ChatSettingsTab/DisplayTab';
|
||||
import {InputTabContent} from './ChatSettingsTab/InputTab';
|
||||
import {InteractionTabContent} from './ChatSettingsTab/InteractionTab';
|
||||
import {MediaTabContent} from './ChatSettingsTab/MediaTab';
|
||||
|
||||
const ChatSettingsTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection
|
||||
id="display"
|
||||
title={t`Display`}
|
||||
description={t`Control how messages, media, and other content are displayed.`}
|
||||
>
|
||||
<DisplayTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="media" title={t`Media`} description={t`Customize media size preferences and buttons.`}>
|
||||
<MediaTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="input" title={t`Input`} description={t`Customize message input settings.`}>
|
||||
<InputTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="interaction"
|
||||
title={t`Interaction`}
|
||||
description={t`Configure message interaction settings.`}
|
||||
isAdvanced
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<InteractionTabContent />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatSettingsTab;
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionContent {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.radioSection {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.radioLabelContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {RenderSpoilers} from '~/Constants';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import styles from './DisplayTab.module.css';
|
||||
|
||||
const spoilerOptions = (t: (m: MessageDescriptor) => string): ReadonlyArray<RadioOption<number>> => [
|
||||
{
|
||||
value: RenderSpoilers.ON_CLICK,
|
||||
name: t(msg`On click`),
|
||||
desc: t(msg`Show spoiler content when clicked`),
|
||||
},
|
||||
{
|
||||
value: RenderSpoilers.IF_MODERATOR,
|
||||
name: t(msg`In channels I moderate`),
|
||||
desc: t(msg`Always show spoiler content in channels where you have the "Manage Messages" permission`),
|
||||
},
|
||||
{
|
||||
value: RenderSpoilers.ALWAYS,
|
||||
name: t(msg`Always`),
|
||||
desc: t(msg`Always show spoiler content`),
|
||||
},
|
||||
];
|
||||
|
||||
export const DisplayTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const userSettings = UserSettingsStore;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title={t(msg`Media Display`)}
|
||||
description={t(
|
||||
msg`Control how images, videos and other media are shown. All media is resized and converted. Extremely large files that cannot be compressed into a preview will not embed regardless of these settings.`,
|
||||
)}
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<SwitchGroup>
|
||||
<SwitchGroupItem
|
||||
label={t(msg`When posted as links to chat`)}
|
||||
value={userSettings.inlineEmbedMedia}
|
||||
onChange={(value) => UserSettingsActionCreators.update({inlineEmbedMedia: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t(msg`When uploaded directly to Fluxer`)}
|
||||
value={userSettings.inlineAttachmentMedia}
|
||||
onChange={(value) => UserSettingsActionCreators.update({inlineAttachmentMedia: value})}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={t(msg`Link Previews`)}
|
||||
description={t(msg`Control how website links are previewed in chat`)}
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<Switch
|
||||
label={t(msg`Show embeds and preview website links`)}
|
||||
value={userSettings.renderEmbeds}
|
||||
onChange={(value) => UserSettingsActionCreators.update({renderEmbeds: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={t(msg`Reactions`)} description={t(msg`Configure emoji reactions on messages`)}>
|
||||
<div className={styles.sectionContent}>
|
||||
<Switch
|
||||
label={t(msg`Show emoji reactions on messages`)}
|
||||
value={userSettings.renderReactions}
|
||||
onChange={(value) => UserSettingsActionCreators.update({renderReactions: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={t(msg`Spoiler Content`)}
|
||||
description={t(msg`Control how spoiler content is displayed`)}
|
||||
>
|
||||
<div className={styles.radioSection}>
|
||||
<div className={styles.radioLabelContainer}>
|
||||
<div className={styles.radioLabel}>
|
||||
<Trans>Show spoiler content</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
options={spoilerOptions(t)}
|
||||
value={userSettings.renderSpoilers}
|
||||
onChange={(value) => UserSettingsActionCreators.update({renderSpoilers: value})}
|
||||
aria-label={t(msg`Show spoiler content`)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {DisplayTabContent} from './DisplayTab';
|
||||
import styles from './Inline.module.css';
|
||||
import {InputTabContent} from './InputTab';
|
||||
import {InteractionTabContent} from './InteractionTab';
|
||||
import {MediaTabContent} from './MediaTab';
|
||||
|
||||
export const ChatSettingsInlineTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SettingsSection id="chat-display" title={t`Display`}>
|
||||
<DisplayTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="chat-media" title={t`Media`}>
|
||||
<MediaTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="chat-input" title={t`Input`}>
|
||||
<InputTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="chat-interaction" title={t`Interaction`}>
|
||||
<InteractionTabContent />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionContent {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {KeyboardKey} from '~/components/uikit/KeyboardKey';
|
||||
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import styles from './InputTab.module.css';
|
||||
|
||||
export const InputTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
|
||||
const isMac = React.useMemo(() => {
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) || /Macintosh/.test(navigator.userAgent);
|
||||
}, []);
|
||||
|
||||
const modifierKey = isMac ? '⌘' : 'Ctrl';
|
||||
|
||||
const {
|
||||
showGiftButton,
|
||||
showGifButton,
|
||||
showMemesButton,
|
||||
showStickersButton,
|
||||
showEmojiButton,
|
||||
showUploadButton,
|
||||
showMessageSendButton,
|
||||
showDefaultEmojisInExpressionAutocomplete,
|
||||
showCustomEmojisInExpressionAutocomplete,
|
||||
showStickersInExpressionAutocomplete,
|
||||
showMemesInExpressionAutocomplete,
|
||||
} = AccessibilityStore;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title="Expression Autocomplete (Colon Autocomplete)"
|
||||
description="Control what appears in the expression autocomplete when you type colon. Customize what suggestions show up to match your preferences."
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<SwitchGroup>
|
||||
<SwitchGroupItem
|
||||
label={t`Show default emojis in expression autocomplete`}
|
||||
value={showDefaultEmojisInExpressionAutocomplete}
|
||||
onChange={(value) =>
|
||||
AccessibilityActionCreators.update({showDefaultEmojisInExpressionAutocomplete: value})
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show custom emojis in expression autocomplete`}
|
||||
value={showCustomEmojisInExpressionAutocomplete}
|
||||
onChange={(value) =>
|
||||
AccessibilityActionCreators.update({showCustomEmojisInExpressionAutocomplete: value})
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show stickers in expression autocomplete`}
|
||||
value={showStickersInExpressionAutocomplete}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showStickersInExpressionAutocomplete: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show saved media in expression autocomplete`}
|
||||
value={showMemesInExpressionAutocomplete}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMemesInExpressionAutocomplete: value})}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
{!mobileLayout.enabled && (
|
||||
<SettingsTabSection
|
||||
title="Message Input Buttons"
|
||||
description="Customize which buttons are visible in the message input area. Keyboard shortcuts will continue to work even if buttons are hidden."
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<SwitchGroup>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Upload Button`}
|
||||
value={showUploadButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showUploadButton: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Gift Button`}
|
||||
value={showGiftButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showGiftButton: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show GIFs Button`}
|
||||
value={showGifButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showGifButton: value})}
|
||||
shortcut={
|
||||
<>
|
||||
<KeyboardKey>{modifierKey}</KeyboardKey>
|
||||
<KeyboardKey>G</KeyboardKey>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Media Button`}
|
||||
value={showMemesButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMemesButton: value})}
|
||||
shortcut={
|
||||
<>
|
||||
<KeyboardKey>{modifierKey}</KeyboardKey>
|
||||
<KeyboardKey>M</KeyboardKey>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Stickers Button`}
|
||||
value={showStickersButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showStickersButton: value})}
|
||||
shortcut={
|
||||
<>
|
||||
<KeyboardKey>{modifierKey}</KeyboardKey>
|
||||
<KeyboardKey>S</KeyboardKey>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Emoji Button`}
|
||||
value={showEmojiButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showEmojiButton: value})}
|
||||
shortcut={
|
||||
<>
|
||||
<KeyboardKey>{modifierKey}</KeyboardKey>
|
||||
<KeyboardKey>E</KeyboardKey>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Send Button`}
|
||||
value={showMessageSendButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageSendButton: value})}
|
||||
shortcut={<KeyboardKey>↵</KeyboardKey>}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionContent {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.previewBox {
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary-lighter);
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.shiftHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shiftHintDisabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.shiftHintText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {MessageStates, MessageTypes} from '~/Constants';
|
||||
import {Message} from '~/components/channel/Message';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {KeyboardKey} from '~/components/uikit/KeyboardKey';
|
||||
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
|
||||
import {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import {MessageRecord} from '~/records/MessageRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {SHIFT_KEY_SYMBOL} from '~/utils/KeyboardUtils';
|
||||
import styles from './InteractionTab.module.css';
|
||||
|
||||
const MessageActionBarPreview = observer(
|
||||
({
|
||||
showActionBar,
|
||||
showShiftExpand,
|
||||
onlyMoreButton,
|
||||
}: {
|
||||
showActionBar: boolean;
|
||||
showQuickReactions: boolean;
|
||||
showShiftExpand: boolean;
|
||||
onlyMoreButton: boolean;
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const fakeData = React.useMemo(() => {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const author = currentUser?.toJSON() || {
|
||||
id: 'preview-user',
|
||||
username: 'PreviewUser',
|
||||
discriminator: '0000',
|
||||
global_name: 'Preview User',
|
||||
avatar: null,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const fakeChannel = new ChannelRecord({
|
||||
id: 'action-bar-fake-channel',
|
||||
type: 0,
|
||||
name: 'action-bar-fake-channel',
|
||||
position: 0,
|
||||
parent_id: null,
|
||||
topic: null,
|
||||
url: null,
|
||||
nsfw: false,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
|
||||
const fakeMessage = new MessageRecord(
|
||||
{
|
||||
id: 'action-bar-preview-message',
|
||||
channel_id: 'action-bar-fake-channel',
|
||||
author,
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
content: showActionBar ? t`This message shows the action bar` : t`This message doesn't show the action bar`,
|
||||
timestamp: new Date().toISOString(),
|
||||
state: MessageStates.SENT,
|
||||
},
|
||||
{skipUserCache: true},
|
||||
);
|
||||
|
||||
return {fakeChannel, fakeMessage};
|
||||
}, [showActionBar]);
|
||||
|
||||
return (
|
||||
<div className={styles.previewContainer}>
|
||||
<div
|
||||
className={styles.previewBox}
|
||||
data-force-show-action-bar={showActionBar ? 'true' : 'false'}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{showActionBar && (
|
||||
<style>
|
||||
{`
|
||||
[data-force-show-action-bar="true"] [class*="buttons"] {
|
||||
opacity: 1 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
[data-force-show-action-bar="true"] [class*="hoverAction"] {
|
||||
opacity: 1 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
<Message
|
||||
channel={fakeData.fakeChannel}
|
||||
message={fakeData.fakeMessage}
|
||||
removeTopSpacing={true}
|
||||
previewMode={true}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.shiftHint} ${!showActionBar || onlyMoreButton || !showShiftExpand ? styles.shiftHintDisabled : ''}`}
|
||||
>
|
||||
<span className={styles.shiftHintText}>{t`Hold`}</span>
|
||||
<KeyboardKey>{SHIFT_KEY_SYMBOL}</KeyboardKey>
|
||||
<span className={styles.shiftHintText}>{t`to expand action bar`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const InteractionTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
|
||||
const {
|
||||
showMessageActionBar,
|
||||
showMessageActionBarQuickReactions,
|
||||
showMessageActionBarShiftExpand,
|
||||
showMessageActionBarOnlyMoreButton,
|
||||
} = AccessibilityStore;
|
||||
|
||||
if (mobileLayout.enabled) {
|
||||
return (
|
||||
<SettingsTabSection
|
||||
title="Message Action Bar"
|
||||
description="Message action bar settings are only available on desktop."
|
||||
>
|
||||
{null}
|
||||
</SettingsTabSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTabSection
|
||||
title="Message Action Bar"
|
||||
description="Customize the action bar that appears when hovering over messages."
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<MessageActionBarPreview
|
||||
showActionBar={showMessageActionBar}
|
||||
showQuickReactions={showMessageActionBarQuickReactions}
|
||||
showShiftExpand={showMessageActionBarShiftExpand}
|
||||
onlyMoreButton={showMessageActionBarOnlyMoreButton}
|
||||
/>
|
||||
<SwitchGroup>
|
||||
<SwitchGroupItem
|
||||
label={t`Show message action bar`}
|
||||
value={showMessageActionBar}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBar: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show only More button`}
|
||||
value={showMessageActionBarOnlyMoreButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarOnlyMoreButton: value})}
|
||||
disabled={!showMessageActionBar}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show quick reactions`}
|
||||
value={showMessageActionBarQuickReactions}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarQuickReactions: value})}
|
||||
disabled={!showMessageActionBar || showMessageActionBarOnlyMoreButton}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Enable Shift to expand`}
|
||||
value={showMessageActionBarShiftExpand}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarShiftExpand: value})}
|
||||
disabled={!showMessageActionBar || showMessageActionBarOnlyMoreButton}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionContent {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.radioSections {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.radioSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.radioLabelContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.previewWrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
max-width: 28rem;
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.previewBox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.previewIcon {
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.gifIndicator {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 10;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-primary);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionButtonIcon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.suppressButton {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: -2rem;
|
||||
display: flex;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.suppressButton:hover {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.expiryFootnotePreview {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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 {DownloadSimpleIcon, ImageSquareIcon, StarIcon, TrashIcon, XIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import {ExpiryFootnote} from '~/components/common/ExpiryFootnote';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
|
||||
import AccessibilityStore, {MediaDimensionSize} from '~/stores/AccessibilityStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import styles from './MediaTab.module.css';
|
||||
|
||||
const MediaPreview = observer(
|
||||
({
|
||||
showFavorite,
|
||||
showDownload,
|
||||
showDelete,
|
||||
showSuppress,
|
||||
showGifIndicator,
|
||||
showExpiryIndicator,
|
||||
}: {
|
||||
showFavorite: boolean;
|
||||
showDownload: boolean;
|
||||
showDelete: boolean;
|
||||
showSuppress: boolean;
|
||||
showGifIndicator: boolean;
|
||||
showExpiryIndicator: boolean;
|
||||
}) => {
|
||||
const previewExpiry = React.useMemo(() => new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), []);
|
||||
return (
|
||||
<div className={styles.previewContainer}>
|
||||
<div className={styles.previewWrapper}>
|
||||
<div className={styles.previewBox}>
|
||||
<ImageSquareIcon className={styles.previewIcon} />
|
||||
{showGifIndicator && <div className={styles.gifIndicator}>GIF</div>}
|
||||
<div className={styles.actionButtons}>
|
||||
{showDelete && (
|
||||
<button type="button" className={styles.actionButton}>
|
||||
<TrashIcon size={18} weight="bold" className={styles.actionButtonIcon} />
|
||||
</button>
|
||||
)}
|
||||
{showDownload && (
|
||||
<button type="button" className={styles.actionButton}>
|
||||
<DownloadSimpleIcon size={18} weight="bold" className={styles.actionButtonIcon} />
|
||||
</button>
|
||||
)}
|
||||
{showFavorite && (
|
||||
<button type="button" className={styles.actionButton}>
|
||||
<StarIcon size={18} weight="bold" className={styles.actionButtonIcon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showSuppress && (
|
||||
<button type="button" className={styles.suppressButton}>
|
||||
<XIcon size={16} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
{showExpiryIndicator && (
|
||||
<ExpiryFootnote expiresAt={previewExpiry} isExpired={false} className={styles.expiryFootnotePreview} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MediaTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const mobileLayout = MobileLayoutStore;
|
||||
|
||||
const {
|
||||
showMediaFavoriteButton,
|
||||
showMediaDownloadButton,
|
||||
showMediaDeleteButton,
|
||||
showSuppressEmbedsButton,
|
||||
showGifIndicator,
|
||||
showAttachmentExpiryIndicator,
|
||||
autoSendTenorGifs,
|
||||
embedMediaDimensionSize,
|
||||
attachmentMediaDimensionSize,
|
||||
} = AccessibilityStore;
|
||||
|
||||
const mediaSizeOptions = React.useMemo(
|
||||
(): ReadonlyArray<RadioOption<MediaDimensionSize>> => [
|
||||
{
|
||||
value: MediaDimensionSize.SMALL,
|
||||
name: t`Compact (400x300)`,
|
||||
desc: t`Smaller media size`,
|
||||
},
|
||||
{
|
||||
value: MediaDimensionSize.LARGE,
|
||||
name: t`Comfortable (550x400)`,
|
||||
desc: t`Larger media size with more detail`,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection
|
||||
title="Media Size Preferences"
|
||||
description="Customize the maximum display size for embedded and attached media. Smaller sizes use less screen space, while larger sizes show more detail."
|
||||
>
|
||||
<div className={styles.radioSections}>
|
||||
<div className={styles.radioSection}>
|
||||
<div className={styles.radioLabelContainer}>
|
||||
<div className={styles.radioLabel}>
|
||||
<Trans>Media from links (embeds)</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
options={mediaSizeOptions}
|
||||
value={embedMediaDimensionSize}
|
||||
onChange={(value) => AccessibilityActionCreators.update({embedMediaDimensionSize: value})}
|
||||
aria-label={t`Select media size for embedded content from links`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.radioSection}>
|
||||
<div className={styles.radioLabelContainer}>
|
||||
<div className={styles.radioLabel}>
|
||||
<Trans>Uploaded attachments</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
options={mediaSizeOptions}
|
||||
value={attachmentMediaDimensionSize}
|
||||
onChange={(value) => AccessibilityActionCreators.update({attachmentMediaDimensionSize: value})}
|
||||
aria-label={t`Select media size for uploaded attachments`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title="GIF Behavior" description="Control how GIFs are inserted into chat">
|
||||
<div className={styles.sectionContent}>
|
||||
<Switch
|
||||
label={t`Automatically send Tenor GIFs when selected`}
|
||||
value={autoSendTenorGifs}
|
||||
onChange={(value) => AccessibilityActionCreators.update({autoSendTenorGifs: value})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
{!mobileLayout.enabled && (
|
||||
<SettingsTabSection
|
||||
title="Media Buttons"
|
||||
description="Customize which buttons appear on media attachments and embeds when hovering over messages."
|
||||
>
|
||||
<div className={styles.sectionContent}>
|
||||
<MediaPreview
|
||||
showFavorite={showMediaFavoriteButton}
|
||||
showDownload={showMediaDownloadButton}
|
||||
showDelete={showMediaDeleteButton}
|
||||
showSuppress={showSuppressEmbedsButton}
|
||||
showGifIndicator={showGifIndicator}
|
||||
showExpiryIndicator={showAttachmentExpiryIndicator}
|
||||
/>
|
||||
<SwitchGroup>
|
||||
<SwitchGroupItem
|
||||
label={t`Show GIF Indicator`}
|
||||
value={showGifIndicator}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showGifIndicator: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Attachment Expiry Indicator`}
|
||||
value={showAttachmentExpiryIndicator}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showAttachmentExpiryIndicator: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Delete Button`}
|
||||
value={showMediaDeleteButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMediaDeleteButton: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Download Button`}
|
||||
value={showMediaDownloadButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMediaDownloadButton: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Favorite Button`}
|
||||
value={showMediaFavoriteButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showMediaFavoriteButton: value})}
|
||||
/>
|
||||
<SwitchGroupItem
|
||||
label={t`Show Suppress Embeds Button`}
|
||||
value={showSuppressEmbedsButton}
|
||||
onChange={(value) => AccessibilityActionCreators.update({showSuppressEmbedsButton: value})}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export {default} from './ComponentGalleryTab/index';
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.buttonsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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 {
|
||||
BookmarkIcon,
|
||||
CheckIcon,
|
||||
DotsThreeOutlineIcon,
|
||||
GearIcon,
|
||||
HeartIcon,
|
||||
LinkSimpleIcon,
|
||||
MegaphoneIcon,
|
||||
PaperPlaneRightIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ShareFatIcon,
|
||||
TrashIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ButtonsTab.module.css';
|
||||
import {SubsectionTitle} from './shared';
|
||||
|
||||
interface ButtonsTabProps {
|
||||
openContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export const ButtonsTab: React.FC<ButtonsTabProps> = observer(({openContextMenu}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Button Variants</Trans>}
|
||||
description={<Trans>Click any button to see toast notifications with feedback.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
leftIcon={<PlusIcon size={16} weight="bold" />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Primary button clicked!`})}
|
||||
>
|
||||
<Trans>Primary</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Secondary button clicked!`})}
|
||||
>
|
||||
<Trans>Secondary</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-primary"
|
||||
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Danger primary clicked!`})}
|
||||
>
|
||||
<Trans>Danger Primary</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-secondary"
|
||||
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Danger secondary clicked!`})}
|
||||
>
|
||||
<Trans>Danger Secondary</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Inverted button clicked!`})}
|
||||
>
|
||||
<Trans>Inverted</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Disabled States</Trans>}>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button disabled>
|
||||
<Trans>Primary (Disabled)</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
<Trans>Secondary (Disabled)</Trans>
|
||||
</Button>
|
||||
<Button variant="danger-primary" disabled>
|
||||
<Trans>Danger (Disabled)</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Button Sizes</Trans>}>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
small
|
||||
leftIcon={<MegaphoneIcon size={14} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Small button clicked!`})}
|
||||
>
|
||||
<Trans>Small Button</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<MegaphoneIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Regular button clicked!`})}
|
||||
>
|
||||
<Trans>Regular Button</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
variant="secondary"
|
||||
leftIcon={<GearIcon size={14} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Small secondary clicked!`})}
|
||||
>
|
||||
<Trans>Small Secondary</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Buttons with Icons</Trans>}>
|
||||
<SubsectionTitle>
|
||||
<Trans>Left Icon</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
leftIcon={<PlusIcon size={16} weight="bold" />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Add action!`})}
|
||||
>
|
||||
<Trans>Add Item</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<GearIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Settings opened!`})}
|
||||
>
|
||||
<Trans>Settings</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-primary"
|
||||
leftIcon={<TrashIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Delete action!`})}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<ShareFatIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Share action!`})}
|
||||
>
|
||||
<Trans>Share</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Right Icon</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
rightIcon={<PaperPlaneRightIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Message sent!`})}
|
||||
>
|
||||
<Trans>Send</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
rightIcon={<LinkSimpleIcon size={16} weight="bold" />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Link copied!`})}
|
||||
>
|
||||
<Trans>Copy Link</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Both Sides</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
leftIcon={<PlusIcon size={16} weight="bold" />}
|
||||
rightIcon={<ShareFatIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Action with both icons!`})}
|
||||
>
|
||||
<Trans>Create & Share</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<HeartIcon size={16} />}
|
||||
rightIcon={<CheckIcon size={16} weight="bold" />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Saved!`})}
|
||||
>
|
||||
<Trans>Save Favorite</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Square Icon Buttons</Trans>}
|
||||
description={<Trans>Compact buttons with just an icon, perfect for toolbars and action bars.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button
|
||||
square
|
||||
aria-label={t`Play`}
|
||||
icon={<PlayIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Play!`})}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
aria-label={t`Settings`}
|
||||
icon={<GearIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Settings!`})}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
variant="secondary"
|
||||
aria-label={t`Bookmark`}
|
||||
icon={<BookmarkIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Bookmarked!`})}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
variant="secondary"
|
||||
aria-label={t`Heart`}
|
||||
icon={<HeartIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Liked!`})}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
variant="danger-primary"
|
||||
aria-label={t`Delete`}
|
||||
icon={<TrashIcon size={16} />}
|
||||
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Deleted!`})}
|
||||
/>
|
||||
<Button square aria-label={t`More`} icon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu} />
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Loading States</Trans>}
|
||||
description={<Trans>Buttons show a loading indicator when submitting is true.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button submitting>
|
||||
<Trans>Submitting</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" submitting>
|
||||
<Trans>Loading</Trans>
|
||||
</Button>
|
||||
<Button small submitting leftIcon={<MegaphoneIcon size={14} />}>
|
||||
<Trans>Small Submitting</Trans>
|
||||
</Button>
|
||||
<Button variant="danger-primary" submitting>
|
||||
<Trans>Processing</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Button with Context Menu</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Buttons can trigger context menus on click by passing the onClick event directly to openContextMenu.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button leftIcon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu}>
|
||||
<Trans>Open Menu</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
square
|
||||
icon={<DotsThreeOutlineIcon size={16} />}
|
||||
aria-label={t`Open Menu (icon)`}
|
||||
onClick={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.itemsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.itemWithLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.itemText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemTextSmall {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemTextBase {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemTextTertiary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.avatarGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.avatarShapes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stacksWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stackItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.badgesWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {ChannelTypes, getStatusTypeLabel, StatusTypes} from '~/Constants';
|
||||
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
|
||||
import {MentionBadge} from '~/components/uikit/MentionBadge';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {UserPartial} from '~/records/UserRecord';
|
||||
import styles from './IndicatorsTab.module.css';
|
||||
import {SubsectionTitle} from './shared';
|
||||
|
||||
const AVATAR_SIZES_WITH_STATUS: Array<16 | 24 | 32 | 36 | 40 | 48 | 80> = [16, 24, 32, 36, 40, 48, 80];
|
||||
|
||||
const AVATAR_STATUSES: Array<string> = [
|
||||
StatusTypes.ONLINE,
|
||||
StatusTypes.IDLE,
|
||||
StatusTypes.DND,
|
||||
StatusTypes.INVISIBLE,
|
||||
StatusTypes.OFFLINE,
|
||||
];
|
||||
|
||||
const createMockRecipient = (id: string): UserPartial => ({
|
||||
id,
|
||||
username: id,
|
||||
discriminator: '0000',
|
||||
avatar: null,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
const createMockGroupDMChannel = (id: string, recipientIds: Array<string>): ChannelRecord =>
|
||||
new ChannelRecord({
|
||||
id,
|
||||
type: ChannelTypes.GROUP_DM,
|
||||
recipients: recipientIds.map(createMockRecipient),
|
||||
});
|
||||
|
||||
const getMockGroupDMChannels = (): Array<ChannelRecord> => [
|
||||
createMockGroupDMChannel('1000000000000000001', ['1000000000000000002', '1000000000000000003']),
|
||||
createMockGroupDMChannel('1000000000000000004', [
|
||||
'1000000000000000005',
|
||||
'1000000000000000006',
|
||||
'1000000000000000007',
|
||||
]),
|
||||
];
|
||||
|
||||
export const IndicatorsTab: React.FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const mockGroupDMChannels = getMockGroupDMChannels();
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Status Indicators</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Visual indicators showing user status, rendered using the same masked status badges as avatars: online,
|
||||
idle, do not disturb, invisible, and offline.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Single User (All Statuses)</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_STATUSES.map((status) => (
|
||||
<div key={status} className={styles.avatarGroup}>
|
||||
<MockAvatar size={40} status={status} />
|
||||
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Mobile Online Status on Avatars</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_SIZES_WITH_STATUS.map((size) => (
|
||||
<div key={`mobile-avatar-size-${size}`} className={styles.avatarGroup}>
|
||||
<MockAvatar size={size} status={StatusTypes.ONLINE} isMobileStatus />
|
||||
<span className={styles.itemTextTertiary}>{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Different Sizes (Status Supported)</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_SIZES_WITH_STATUS.map((size) => (
|
||||
<div key={size} className={styles.avatarGroup}>
|
||||
<MockAvatar size={size} status={StatusTypes.ONLINE} />
|
||||
<span className={styles.itemTextTertiary}>{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Mention Badges</Trans>}
|
||||
description={<Trans>Notification badges showing unread mention counts in different sizes.</Trans>}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Medium Size (Default)</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.badgesWrapper}>
|
||||
<MentionBadge mentionCount={1} />
|
||||
<MentionBadge mentionCount={5} />
|
||||
<MentionBadge mentionCount={12} />
|
||||
<MentionBadge mentionCount={99} />
|
||||
<MentionBadge mentionCount={150} />
|
||||
<MentionBadge mentionCount={1000} />
|
||||
<MentionBadge mentionCount={9999} />
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Small Size</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.badgesWrapper}>
|
||||
<MentionBadge mentionCount={1} size="small" />
|
||||
<MentionBadge mentionCount={5} size="small" />
|
||||
<MentionBadge mentionCount={12} size="small" />
|
||||
<MentionBadge mentionCount={99} size="small" />
|
||||
<MentionBadge mentionCount={150} size="small" />
|
||||
<MentionBadge mentionCount={1000} size="small" />
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Mock Avatars</Trans>}
|
||||
description={<Trans>Mock user avatars in various sizes and all status permutations.</Trans>}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Different Sizes (Online)</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_SIZES_WITH_STATUS.map((size) => (
|
||||
<div key={size} className={styles.avatarGroup}>
|
||||
<MockAvatar size={size} status={StatusTypes.ONLINE} />
|
||||
<span className={styles.itemTextTertiary}>{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>All Status Types</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_STATUSES.map((status) => (
|
||||
<div key={status} className={styles.avatarGroup}>
|
||||
<MockAvatar size={48} status={status} />
|
||||
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Typing State</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
{AVATAR_STATUSES.map((status) => (
|
||||
<div key={status} className={styles.avatarGroup}>
|
||||
<MockAvatar size={48} status={status} isTyping />
|
||||
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Group DM Avatars</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Group DM avatars using the same status masks as regular avatars, including stacked layouts and typing
|
||||
states.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Different Sizes & Member Counts</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} />
|
||||
<span className={styles.itemTextTertiary}>32px · 2 members</span>
|
||||
</div>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} />
|
||||
<span className={styles.itemTextTertiary}>40px · 3 members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Group Online Status</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} statusOverride={StatusTypes.ONLINE} />
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>2 members (online)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} statusOverride={StatusTypes.ONLINE} />
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>3 members (online)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Group Typing States</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.itemsWrapper}>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} isTyping />
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>2 members (typing)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.avatarGroup}>
|
||||
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} isTyping />
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>3 members (typing)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Avatar Stacks</Trans>}
|
||||
description={<Trans>Overlapping avatar groups showing multiple users with automatic overflow counts.</Trans>}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Different Sizes</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.stacksWrapper}>
|
||||
<div className={styles.stackItem}>
|
||||
<AvatarStack size={24}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<MockAvatar key={i} size={24} userTag={`User ${i}`} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
<span className={styles.itemTextTertiary}>24px</span>
|
||||
</div>
|
||||
<div className={styles.stackItem}>
|
||||
<AvatarStack size={32}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
<span className={styles.itemTextTertiary}>32px</span>
|
||||
</div>
|
||||
<div className={styles.stackItem}>
|
||||
<AvatarStack size={40}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<MockAvatar key={i} size={40} userTag={`User ${i}`} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
<span className={styles.itemTextTertiary}>40px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Max Visible Count</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.stacksWrapper}>
|
||||
<div className={styles.stackItem}>
|
||||
<AvatarStack size={32} maxVisible={3}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>Show max 3 (+5 badge)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.stackItem}>
|
||||
<AvatarStack size={32} maxVisible={5}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => (
|
||||
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
<span className={styles.itemTextTertiary}>
|
||||
<Trans>Show max 5 (+5 badge)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 {
|
||||
DotsThreeOutlineIcon,
|
||||
GearIcon,
|
||||
LinkSimpleIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ShareFatIcon,
|
||||
TrashIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {openFromEvent} from '~/actions/ContextMenuActionCreators';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
import {MenuGroups} from '~/components/uikit/ContextMenu/MenuGroups';
|
||||
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
|
||||
import {MenuItemCheckbox} from '~/components/uikit/ContextMenu/MenuItemCheckbox';
|
||||
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
|
||||
import {MenuItemSlider} from '~/components/uikit/ContextMenu/MenuItemSlider';
|
||||
import {MenuItemSubmenu} from '~/components/uikit/ContextMenu/MenuItemSubmenu';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {ButtonsTab} from './ButtonsTab';
|
||||
import {IndicatorsTab} from './IndicatorsTab';
|
||||
import styles from './Inline.module.css';
|
||||
import {InputsTab} from './InputsTab';
|
||||
import {OverlaysTab} from './OverlaysTab';
|
||||
import {SelectionsTab} from './SelectionsTab';
|
||||
|
||||
export const ComponentGalleryInlineTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const [primarySwitch, setPrimarySwitch] = React.useState(true);
|
||||
const [dangerSwitch, setDangerSwitch] = React.useState(false);
|
||||
const [selectValue, setSelectValue] = React.useState('opt1');
|
||||
const [selectValue2, setSelectValue2] = React.useState('size-md');
|
||||
const [sliderValue, setSliderValue] = React.useState(42);
|
||||
const [sliderValue2, setSliderValue2] = React.useState(75);
|
||||
const [sliderValue3, setSliderValue3] = React.useState(50);
|
||||
const [sliderValue4, setSliderValue4] = React.useState(75);
|
||||
const [sliderValue5, setSliderValue5] = React.useState(60);
|
||||
const [color, setColor] = React.useState(0x3b82f6);
|
||||
const [color2, setColor2] = React.useState(0xff5733);
|
||||
const [radioValue, setRadioValue] = React.useState<'a' | 'b' | 'c'>('a');
|
||||
const [checkOne, setCheckOne] = React.useState(true);
|
||||
const [checkTwo, setCheckTwo] = React.useState(false);
|
||||
const [checkboxChecked, setCheckboxChecked] = React.useState(false);
|
||||
const [checkboxChecked2, setCheckboxChecked2] = React.useState(true);
|
||||
const [radioGroupValue, setRadioGroupValue] = React.useState<string>('option1');
|
||||
const [inputValue1, setInputValue1] = React.useState('');
|
||||
const [inputValue2, setInputValue2] = React.useState('');
|
||||
const [inputValue3, setInputValue3] = React.useState('');
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [emailValue, setEmailValue] = React.useState('');
|
||||
const [passwordValue, setPasswordValue] = React.useState('');
|
||||
const [textareaValue1, setTextareaValue1] = React.useState('');
|
||||
const [textareaValue2, setTextareaValue2] = React.useState('This is some example text in the textarea.');
|
||||
const [inlineEditValue, setInlineEditValue] = React.useState('EditableText');
|
||||
|
||||
const radioOptions: Array<RadioOption<string>> = [
|
||||
{value: 'option1', name: t`First Option`, desc: t`This is the first option description`},
|
||||
{value: 'option2', name: t`Second Option`, desc: t`This is the second option description`},
|
||||
{value: 'option3', name: t`Third Option`, desc: t`This is the third option description`},
|
||||
];
|
||||
|
||||
const handleOpenContextMenu = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
openFromEvent(event, ({onClose}) => (
|
||||
<MenuGroups>
|
||||
<MenuGroup>
|
||||
<MenuItem icon={<GearIcon size={16} />} onClick={() => onClose()}>
|
||||
<Trans>Settings</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem icon={<ShareFatIcon size={16} />} onClick={() => onClose()}>
|
||||
<Trans>Share</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem icon={<LinkSimpleIcon size={16} weight="bold" />} onClick={() => onClose()}>
|
||||
<Trans>Copy Link</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkOne} onChange={setCheckOne}>
|
||||
<Trans>Enable Extra Option</Trans>
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkTwo} onChange={setCheckTwo}>
|
||||
<Trans>Enable Beta Feature</Trans>
|
||||
</MenuItemCheckbox>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'a'}
|
||||
onSelect={() => setRadioValue('a')}
|
||||
>
|
||||
<Trans>Mode A</Trans>
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'b'}
|
||||
onSelect={() => setRadioValue('b')}
|
||||
>
|
||||
<Trans>Mode B</Trans>
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'c'}
|
||||
onSelect={() => setRadioValue('c')}
|
||||
>
|
||||
<Trans>Mode C</Trans>
|
||||
</MenuItemRadio>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemSlider
|
||||
label={t`Opacity`}
|
||||
value={sliderValue}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
onChange={(v: number) => setSliderValue(Math.round(v))}
|
||||
onFormat={(v: number) => `${Math.round(v)}%`}
|
||||
/>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemSubmenu
|
||||
label={t`More Actions`}
|
||||
icon={<DotsThreeOutlineIcon size={16} />}
|
||||
render={() => (
|
||||
<>
|
||||
<MenuItem onClick={() => onClose()}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onClose()}>
|
||||
<Trans>Archive</Trans>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<MenuItem icon={<TrashIcon size={16} />} danger onClick={() => onClose()}>
|
||||
<Trans>Delete</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
</MenuGroups>
|
||||
));
|
||||
},
|
||||
[checkOne, checkTwo, radioValue, sliderValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t`Buttons`}</h3>
|
||||
<ButtonsTab openContextMenu={handleOpenContextMenu} />
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t`Inputs & Text`}</h3>
|
||||
<InputsTab
|
||||
inputValue1={inputValue1}
|
||||
setInputValue1={setInputValue1}
|
||||
inputValue2={inputValue2}
|
||||
setInputValue2={setInputValue2}
|
||||
inputValue3={inputValue3}
|
||||
setInputValue3={setInputValue3}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
emailValue={emailValue}
|
||||
setEmailValue={setEmailValue}
|
||||
passwordValue={passwordValue}
|
||||
setPasswordValue={setPasswordValue}
|
||||
textareaValue1={textareaValue1}
|
||||
setTextareaValue1={setTextareaValue1}
|
||||
textareaValue2={textareaValue2}
|
||||
setTextareaValue2={setTextareaValue2}
|
||||
inlineEditValue={inlineEditValue}
|
||||
setInlineEditValue={setInlineEditValue}
|
||||
color={color}
|
||||
setColor={setColor}
|
||||
color2={color2}
|
||||
setColor2={setColor2}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t`Selections`}</h3>
|
||||
<SelectionsTab
|
||||
selectValue={selectValue}
|
||||
setSelectValue={setSelectValue}
|
||||
selectValue2={selectValue2}
|
||||
setSelectValue2={setSelectValue2}
|
||||
primarySwitch={primarySwitch}
|
||||
setPrimarySwitch={setPrimarySwitch}
|
||||
dangerSwitch={dangerSwitch}
|
||||
setDangerSwitch={setDangerSwitch}
|
||||
checkboxChecked={checkboxChecked}
|
||||
setCheckboxChecked={setCheckboxChecked}
|
||||
checkboxChecked2={checkboxChecked2}
|
||||
setCheckboxChecked2={setCheckboxChecked2}
|
||||
radioGroupValue={radioGroupValue}
|
||||
setRadioGroupValue={setRadioGroupValue}
|
||||
radioOptions={radioOptions}
|
||||
sliderValue={sliderValue}
|
||||
setSliderValue={setSliderValue}
|
||||
sliderValue2={sliderValue2}
|
||||
setSliderValue2={setSliderValue2}
|
||||
sliderValue3={sliderValue3}
|
||||
setSliderValue3={setSliderValue3}
|
||||
sliderValue4={sliderValue4}
|
||||
setSliderValue4={setSliderValue4}
|
||||
sliderValue5={sliderValue5}
|
||||
setSliderValue5={setSliderValue5}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t`Overlays & Menus`}</h3>
|
||||
<OverlaysTab openContextMenu={handleOpenContextMenu} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>{t`Indicators & Status`}</h3>
|
||||
<IndicatorsTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.gridSingle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineEditWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineEditLabel {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.inlineEditWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineEditCaption {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.colorPickersGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.colorPickersGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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 {MagnifyingGlassIcon, UserIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ColorPickerField} from '~/components/form/ColorPickerField';
|
||||
import {Input, Textarea} from '~/components/form/Input';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {InlineEdit} from '~/components/uikit/InlineEdit';
|
||||
import styles from './InputsTab.module.css';
|
||||
|
||||
interface InputsTabProps {
|
||||
inputValue1: string;
|
||||
setInputValue1: (value: string) => void;
|
||||
inputValue2: string;
|
||||
setInputValue2: (value: string) => void;
|
||||
inputValue3: string;
|
||||
setInputValue3: (value: string) => void;
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
emailValue: string;
|
||||
setEmailValue: (value: string) => void;
|
||||
passwordValue: string;
|
||||
setPasswordValue: (value: string) => void;
|
||||
textareaValue1: string;
|
||||
setTextareaValue1: (value: string) => void;
|
||||
textareaValue2: string;
|
||||
setTextareaValue2: (value: string) => void;
|
||||
inlineEditValue: string;
|
||||
setInlineEditValue: (value: string) => void;
|
||||
color: number;
|
||||
setColor: (value: number) => void;
|
||||
color2: number;
|
||||
setColor2: (value: number) => void;
|
||||
}
|
||||
|
||||
export const InputsTab: React.FC<InputsTabProps> = observer(
|
||||
({
|
||||
inputValue1,
|
||||
setInputValue1,
|
||||
inputValue2,
|
||||
setInputValue2,
|
||||
inputValue3,
|
||||
setInputValue3,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
emailValue,
|
||||
setEmailValue,
|
||||
passwordValue,
|
||||
setPasswordValue,
|
||||
textareaValue1,
|
||||
setTextareaValue1,
|
||||
textareaValue2,
|
||||
setTextareaValue2,
|
||||
inlineEditValue,
|
||||
setInlineEditValue,
|
||||
color,
|
||||
setColor,
|
||||
color2,
|
||||
setColor2,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Basic Text Inputs</Trans>}
|
||||
description={<Trans>All inputs are fully interactive - type to test them out!</Trans>}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
<Input
|
||||
label={t`Display Name`}
|
||||
placeholder={t`Enter your name`}
|
||||
value={inputValue1}
|
||||
onChange={(e) => setInputValue1(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t`Username`}
|
||||
placeholder={t`@username`}
|
||||
value={inputValue2}
|
||||
onChange={(e) => setInputValue2(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t`Email Address`}
|
||||
type="email"
|
||||
placeholder={t`your@email.com`}
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t`Password`}
|
||||
type="password"
|
||||
placeholder={t`Enter a secure password`}
|
||||
value={passwordValue}
|
||||
onChange={(e) => setPasswordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Inputs with Icons</Trans>}>
|
||||
<div className={styles.grid}>
|
||||
<Input
|
||||
label={t`Search`}
|
||||
placeholder={t`Search for anything...`}
|
||||
leftIcon={<MagnifyingGlassIcon size={16} weight="bold" />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t`User Profile`}
|
||||
placeholder={t`Enter username`}
|
||||
leftIcon={<UserIcon size={16} />}
|
||||
value={inputValue3}
|
||||
onChange={(e) => setInputValue3(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Input States</Trans>}>
|
||||
<div className={styles.grid}>
|
||||
<Input
|
||||
label={t`With Error`}
|
||||
placeholder={t`This field has an error`}
|
||||
error={t`This is an error message`}
|
||||
/>
|
||||
<Input label={t`Disabled Input`} placeholder={t`Cannot be edited`} disabled value="Disabled value" />
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Textarea</Trans>}>
|
||||
<div className={styles.grid}>
|
||||
<Textarea
|
||||
label={t`About You`}
|
||||
placeholder={t`Write a short bio (max 280 characters)`}
|
||||
maxLength={280}
|
||||
showCharacterCount
|
||||
value={textareaValue1}
|
||||
onChange={(e) => setTextareaValue1(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={t`Message`}
|
||||
placeholder={t`Type your message here...`}
|
||||
minRows={3}
|
||||
value={textareaValue2}
|
||||
onChange={(e) => setTextareaValue2(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.gridSingle}>
|
||||
<Textarea
|
||||
label={t`Long Form Content`}
|
||||
placeholder={t`Write your content here... This textarea expands as you type.`}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
value={textareaValue1}
|
||||
onChange={(e) => setTextareaValue1(e.target.value)}
|
||||
footer={
|
||||
<p className={styles.inlineEditLabel}>
|
||||
<Trans>This textarea auto-expands between 4-12 rows as you type.</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Inline Edit</Trans>}
|
||||
description={
|
||||
<Trans>Click the text below to edit it inline. Press Enter to save or Escape to cancel.</Trans>
|
||||
}
|
||||
>
|
||||
<div className={styles.inlineEditWrapper}>
|
||||
<span className={styles.inlineEditCaption}>
|
||||
<Trans>Editable Text:</Trans>
|
||||
</span>
|
||||
<InlineEdit
|
||||
value={inlineEditValue}
|
||||
onSave={(newValue) => {
|
||||
setInlineEditValue(newValue);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Value saved: ${newValue}`});
|
||||
}}
|
||||
placeholder={t`Enter text`}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Color Pickers</Trans>}
|
||||
description={<Trans>Click to open the color picker and choose a new color.</Trans>}
|
||||
>
|
||||
<div className={styles.colorPickersGrid}>
|
||||
<ColorPickerField label={t`Primary Accent Color`} value={color} onChange={setColor} />
|
||||
<ColorPickerField label={t`Secondary Accent Color`} value={color2} onChange={setColor2} />
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* 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 {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants';
|
||||
import {Message} from '~/components/channel/Message';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabHeader,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import {MessageRecord} from '~/records/MessageRecord';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import appearanceStyles from '../AppearanceTab.module.css';
|
||||
import styles from './MarkdownTab.module.css';
|
||||
import {SubsectionTitle} from './shared';
|
||||
|
||||
export const MarkdownTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
const createMarkdownPreviewMessages = React.useCallback(() => {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const author = currentUser?.toJSON() || {
|
||||
id: 'markdown-preview-user',
|
||||
username: 'MarkdownUser',
|
||||
discriminator: '0000',
|
||||
global_name: 'Markdown Preview User',
|
||||
avatar: null,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const fakeChannel = new ChannelRecord({
|
||||
id: 'markdown-preview-channel',
|
||||
type: 0,
|
||||
name: 'markdown-preview',
|
||||
position: 0,
|
||||
parent_id: null,
|
||||
topic: null,
|
||||
url: null,
|
||||
nsfw: false,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
|
||||
const tabOpenedAt = new Date();
|
||||
|
||||
const markdownSections = [
|
||||
{
|
||||
title: t`Text Formatting`,
|
||||
items: [
|
||||
{label: '**bold**', content: '**bold text**'},
|
||||
{label: '*italic*', content: '*italic text*'},
|
||||
{label: '***bold italic***', content: '***bold italic***'},
|
||||
{label: '__underline__', content: '__underline text__'},
|
||||
{label: '~~strikethrough~~', content: '~~strikethrough text~~'},
|
||||
{label: '`code`', content: '`inline code`'},
|
||||
{label: '||spoiler||', content: '||spoiler text||'},
|
||||
{label: '\\*escaped\\*', content: '\\*escaped asterisks\\*'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Headings`,
|
||||
items: [
|
||||
{label: '#', content: '# Heading 1'},
|
||||
{label: '##', content: '## Heading 2'},
|
||||
{label: '###', content: '### Heading 3'},
|
||||
{label: '####', content: '#### Heading 4'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Links`,
|
||||
items: [
|
||||
{label: '[text](url)', content: '[Masked Link](https://fluxer.app)'},
|
||||
{label: '<url>', content: '<https://fluxer.app>'},
|
||||
{label: 'url', content: 'https://fluxer.app'},
|
||||
{label: '<email>', content: '<contact@fluxer.app>'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Lists`,
|
||||
items: [
|
||||
{label: 'Unordered', content: '- First item\n- Second item\n- Third item'},
|
||||
{label: 'Ordered', content: '1. First item\n2. Second item\n3. Third item'},
|
||||
{
|
||||
label: 'Nested',
|
||||
content: '- Parent item\n - Nested item\n - Another nested\n- Another parent',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Blockquotes`,
|
||||
items: [
|
||||
{label: 'Single line', content: '> Single line quote'},
|
||||
{label: 'Multi-line', content: '> Multi-line quote\n> Spans multiple lines\n> Continues here'},
|
||||
{
|
||||
label: 'Alternative',
|
||||
content: '>>> Multi-line quote\nContinues without > on each line\nUntil the message ends',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Code Blocks`,
|
||||
items: [
|
||||
{label: 'Plain', content: '```\nfunction example() {\n return "Hello";\n}\n```'},
|
||||
{
|
||||
label: 'JavaScript',
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is intended
|
||||
content: '```js\nfunction greet(name) {\n console.log(`Hello, ${name}!`);\n}\n```',
|
||||
},
|
||||
{
|
||||
label: 'Python',
|
||||
content: '```py\ndef factorial(n):\n return 1 if n <= 1 else n * factorial(n-1)\n```',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Tables`,
|
||||
items: [
|
||||
{
|
||||
label: 'Basic',
|
||||
content: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
|
||||
},
|
||||
{
|
||||
label: 'Aligned',
|
||||
content: '| Left | Center | Right |\n|:-----|:------:|------:|\n| L1 | C1 | R1 |',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Alerts / Callouts`,
|
||||
items: [
|
||||
{label: '[!NOTE]', content: '> [!NOTE]\n> Helpful information here'},
|
||||
{label: '[!TIP]', content: '> [!TIP]\n> Useful suggestion here'},
|
||||
{label: '[!IMPORTANT]', content: '> [!IMPORTANT]\n> Critical information here'},
|
||||
{label: '[!WARNING]', content: '> [!WARNING]\n> Exercise caution here'},
|
||||
{label: '[!CAUTION]', content: '> [!CAUTION]\n> Potential risks here'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Special Features`,
|
||||
items: [
|
||||
{label: 'Subtext', content: '-# This is subtext that appears smaller and dimmed'},
|
||||
{label: 'Block Spoiler', content: '||\nBlock spoiler content\nClick to reveal!\n||'},
|
||||
{label: 'Unicode Emojis', content: '🎉 🚀 ❤️ 👍 😀'},
|
||||
{label: 'Shortcodes', content: ':tm: :copyright: :registered:'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Mentions & Timestamps`,
|
||||
items: [
|
||||
{label: '@everyone', content: '@everyone'},
|
||||
{label: '@here', content: '@here'},
|
||||
{label: 'Short Time', content: '<t:1618936830:t>'},
|
||||
{label: 'Long Time', content: '<t:1618936830:T>'},
|
||||
{label: 'Short Date', content: '<t:1618936830:d>'},
|
||||
{label: 'Long Date', content: '<t:1618936830:D>'},
|
||||
{label: 'Default', content: '<t:1618936830:f>'},
|
||||
{label: 'Full', content: '<t:1618936830:F>'},
|
||||
{label: 'Short Date/Time', content: '<t:1618936830:s>'},
|
||||
{label: 'Relative', content: '<t:1618936830:R>'},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
fakeChannel,
|
||||
createMessage: (content: string, id: string) => {
|
||||
return new MessageRecord(
|
||||
{
|
||||
id: `markdown-preview-${id}`,
|
||||
channel_id: 'markdown-preview-channel',
|
||||
author,
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
content,
|
||||
timestamp: tabOpenedAt.toISOString(),
|
||||
state: MessageStates.SENT,
|
||||
},
|
||||
{skipUserCache: true},
|
||||
);
|
||||
},
|
||||
markdownSections,
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const {fakeChannel, createMessage, markdownSections} = createMarkdownPreviewMessages();
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabHeader
|
||||
title={<Trans>Markdown Preview</Trans>}
|
||||
description={<Trans>Each message below demonstrates a single markdown feature with live preview.</Trans>}
|
||||
/>
|
||||
<SettingsTabContent>
|
||||
<div className={styles.sectionsContainer}>
|
||||
{markdownSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<SubsectionTitle>{section.title}</SubsectionTitle>
|
||||
</div>
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const message = createMessage(item.content, `${sectionIndex}-${itemIndex}`);
|
||||
return (
|
||||
<div key={itemIndex} className={styles.item}>
|
||||
<div className={styles.itemHeader}>
|
||||
<code className={styles.itemLabel}>{item.label}</code>
|
||||
</div>
|
||||
<div className={appearanceStyles.previewWrapper}>
|
||||
<div
|
||||
className={clsx(appearanceStyles.previewContainer, appearanceStyles.previewContainerCozy)}
|
||||
style={{
|
||||
height: 'auto',
|
||||
minHeight: '60px',
|
||||
maxHeight: '300px',
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
className={appearanceStyles.previewMessagesContainer}
|
||||
style={{
|
||||
height: 'auto',
|
||||
minHeight: '60px',
|
||||
maxHeight: '280px',
|
||||
pointerEvents: 'auto',
|
||||
paddingTop: '16px',
|
||||
paddingBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Message
|
||||
channel={fakeChannel}
|
||||
message={message}
|
||||
previewContext={MessagePreviewContext.SETTINGS}
|
||||
shouldGroup={false}
|
||||
/>
|
||||
</Scroller>
|
||||
<div className={appearanceStyles.previewOverlay} style={{pointerEvents: 'none'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.buttonsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.demoArea {
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 {DotsThreeOutlineIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './OverlaysTab.module.css';
|
||||
|
||||
interface OverlaysTabProps {
|
||||
openContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export const OverlaysTab: React.FC<OverlaysTabProps> = observer(({openContextMenu}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Tooltips</Trans>}
|
||||
description={<Trans>Hover over buttons to see tooltips in different positions.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Tooltip text={t`I am a tooltip`}>
|
||||
<Button>
|
||||
<Trans>Hover Me</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text={t`Top Tooltip`} position="top">
|
||||
<Button variant="secondary">
|
||||
<Trans>Top</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text={t`Right Tooltip`} position="right">
|
||||
<Button variant="secondary">
|
||||
<Trans>Right</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text={t`Bottom Tooltip`} position="bottom">
|
||||
<Button variant="secondary">
|
||||
<Trans>Bottom</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text={t`Left Tooltip`} position="left">
|
||||
<Button variant="secondary">
|
||||
<Trans>Left</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Toasts</Trans>}
|
||||
description={<Trans>Toasts appear in the top-center of the screen.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Great Success!`})}>
|
||||
<Trans>Success</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-primary"
|
||||
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Something went wrong.`})}
|
||||
>
|
||||
<Trans>Error</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Context Menus</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Context menus can be opened with left-click (on buttons) or right-click (on other elements). This
|
||||
demonstrates various menu items including checkboxes, radio buttons, sliders, and submenus.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<div className={styles.buttonsWrapper}>
|
||||
<Button leftIcon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu}>
|
||||
<Trans>Open Menu</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
square
|
||||
icon={<DotsThreeOutlineIcon size={16} />}
|
||||
aria-label={t`Open Menu (icon)`}
|
||||
onClick={openContextMenu}
|
||||
/>
|
||||
<div role="button" tabIndex={0} onContextMenu={openContextMenu} className={styles.demoArea}>
|
||||
<Trans>Right-click here to open the context menu</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.descriptionSmall {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gridDouble {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.gridDouble {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.gridSingle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sliderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sliderContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sliderValue {
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sliderValueDisabled {
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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 {t} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {Select} from '~/components/form/Select';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {RadioGroup, type RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import styles from './SelectionsTab.module.css';
|
||||
import {SubsectionTitle} from './shared';
|
||||
|
||||
interface SelectionsTabProps {
|
||||
selectValue: string;
|
||||
setSelectValue: (value: string) => void;
|
||||
selectValue2: string;
|
||||
setSelectValue2: (value: string) => void;
|
||||
primarySwitch: boolean;
|
||||
setPrimarySwitch: (value: boolean) => void;
|
||||
dangerSwitch: boolean;
|
||||
setDangerSwitch: (value: boolean) => void;
|
||||
checkboxChecked: boolean;
|
||||
setCheckboxChecked: (value: boolean) => void;
|
||||
checkboxChecked2: boolean;
|
||||
setCheckboxChecked2: (value: boolean) => void;
|
||||
radioGroupValue: string;
|
||||
setRadioGroupValue: (value: string) => void;
|
||||
radioOptions: Array<RadioOption<string>>;
|
||||
sliderValue: number;
|
||||
setSliderValue: (value: number) => void;
|
||||
sliderValue2: number;
|
||||
setSliderValue2: (value: number) => void;
|
||||
sliderValue3: number;
|
||||
setSliderValue3: (value: number) => void;
|
||||
sliderValue4: number;
|
||||
setSliderValue4: (value: number) => void;
|
||||
sliderValue5: number;
|
||||
setSliderValue5: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SelectionsTab: React.FC<SelectionsTabProps> = observer(
|
||||
({
|
||||
selectValue,
|
||||
setSelectValue,
|
||||
selectValue2,
|
||||
setSelectValue2,
|
||||
primarySwitch,
|
||||
setPrimarySwitch,
|
||||
dangerSwitch,
|
||||
setDangerSwitch,
|
||||
checkboxChecked,
|
||||
setCheckboxChecked,
|
||||
checkboxChecked2,
|
||||
setCheckboxChecked2,
|
||||
radioGroupValue,
|
||||
setRadioGroupValue,
|
||||
radioOptions,
|
||||
sliderValue,
|
||||
setSliderValue,
|
||||
sliderValue2,
|
||||
setSliderValue2,
|
||||
sliderValue3,
|
||||
setSliderValue3,
|
||||
sliderValue4,
|
||||
setSliderValue4,
|
||||
sliderValue5,
|
||||
}) => {
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsTabSection
|
||||
title={<Trans>Select Dropdown</Trans>}
|
||||
description={<Trans>Click to open the dropdown menu and select different options.</Trans>}
|
||||
>
|
||||
<div className={styles.gridDouble}>
|
||||
<Select<string>
|
||||
label={t`Choose an option`}
|
||||
value={selectValue}
|
||||
onChange={(value) => {
|
||||
setSelectValue(value);
|
||||
}}
|
||||
options={[
|
||||
{value: 'opt1', label: t`Option One`},
|
||||
{value: 'opt2', label: t`Option Two`},
|
||||
{value: 'opt3', label: t`Option Three`},
|
||||
{value: 'opt4', label: t`Option Four`},
|
||||
]}
|
||||
/>
|
||||
<Select<string>
|
||||
label={t`Size Selection`}
|
||||
value={selectValue2}
|
||||
onChange={(value) => {
|
||||
setSelectValue2(value);
|
||||
}}
|
||||
options={[
|
||||
{value: 'size-sm', label: t`Small`},
|
||||
{value: 'size-md', label: t`Medium`},
|
||||
{value: 'size-lg', label: t`Large`},
|
||||
{value: 'size-xl', label: t`Extra Large`},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.gridSingle}>
|
||||
<Select
|
||||
label={t`Disabled Select`}
|
||||
value="disabled-opt"
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
options={[{value: 'disabled-opt', label: t`This is disabled`}]}
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Switches</Trans>}
|
||||
description={<Trans>Toggle switches on and off to see state changes.</Trans>}
|
||||
>
|
||||
<div className={styles.contentList}>
|
||||
<Switch
|
||||
label={t`Enable Notifications`}
|
||||
description={t`Receive notifications when someone mentions you`}
|
||||
value={primarySwitch}
|
||||
onChange={(value) => {
|
||||
setPrimarySwitch(value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Dark Mode`}
|
||||
description={t`Use dark theme across the application`}
|
||||
value={dangerSwitch}
|
||||
onChange={(value) => {
|
||||
setDangerSwitch(value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Disabled Switch`}
|
||||
description={t`This switch is disabled and cannot be toggled`}
|
||||
value={false}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
<Switch
|
||||
label={t`Disabled (Checked)`}
|
||||
description={t`This switch is disabled in the checked state`}
|
||||
value={true}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Checkboxes</Trans>}
|
||||
description={<Trans>Click to check and uncheck. Available in square and round styles.</Trans>}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Square Checkboxes</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.contentList}>
|
||||
<Checkbox
|
||||
checked={checkboxChecked}
|
||||
onChange={(checked) => {
|
||||
setCheckboxChecked(checked);
|
||||
}}
|
||||
>
|
||||
<Trans>Interactive Checkbox</Trans>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={checkboxChecked2}
|
||||
onChange={(checked) => {
|
||||
setCheckboxChecked2(checked);
|
||||
}}
|
||||
>
|
||||
<Trans>Another Checkbox</Trans>
|
||||
</Checkbox>
|
||||
<Checkbox checked={true} disabled>
|
||||
<Trans>Disabled (Checked)</Trans>
|
||||
</Checkbox>
|
||||
<Checkbox checked={false} disabled>
|
||||
<Trans>Disabled (Unchecked)</Trans>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Round Checkboxes</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.contentList}>
|
||||
<Checkbox checked={checkboxChecked} onChange={(checked) => setCheckboxChecked(checked)} type="round">
|
||||
<Trans>Round Style Checkbox</Trans>
|
||||
</Checkbox>
|
||||
<Checkbox checked={checkboxChecked2} onChange={(checked) => setCheckboxChecked2(checked)} type="round">
|
||||
<Trans>Another Round Checkbox</Trans>
|
||||
</Checkbox>
|
||||
<Checkbox checked={true} disabled type="round">
|
||||
<Trans>Disabled Round (Checked)</Trans>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Radio Group</Trans>}
|
||||
description={<Trans>Radio buttons allow selecting one option from a group.</Trans>}
|
||||
>
|
||||
<RadioGroup
|
||||
aria-label={t`Select an option from the radio group`}
|
||||
options={radioOptions}
|
||||
value={radioGroupValue}
|
||||
onChange={(value) => {
|
||||
setRadioGroupValue(value);
|
||||
}}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={<Trans>Sliders</Trans>}
|
||||
description={
|
||||
<Trans>Drag the slider handles to adjust values. Click markers to jump to specific values.</Trans>
|
||||
}
|
||||
>
|
||||
<SubsectionTitle>
|
||||
<Trans>Standard Slider with Markers</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.sliderRow}>
|
||||
<div className={styles.sliderContainer}>
|
||||
<Slider
|
||||
defaultValue={sliderValue}
|
||||
factoryDefaultValue={42}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
onValueChange={(v) => setSliderValue(Math.round(v))}
|
||||
onValueRender={(v) => `${Math.round(v)}%`}
|
||||
markers={[0, 25, 50, 75, 100]}
|
||||
onMarkerRender={(m) => `${m}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValue}>{sliderValue}%</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Slider with Fewer Markers</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.sliderRow}>
|
||||
<div className={styles.sliderContainer}>
|
||||
<Slider
|
||||
defaultValue={sliderValue2}
|
||||
factoryDefaultValue={75}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
onValueChange={(v) => setSliderValue2(Math.round(v))}
|
||||
onValueRender={(v) => `${Math.round(v)}%`}
|
||||
markers={[0, 50, 100]}
|
||||
onMarkerRender={(m) => `${m}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValue}>{sliderValue2}%</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Slider with Step Values</Trans>
|
||||
</SubsectionTitle>
|
||||
<p className={styles.descriptionSmall}>
|
||||
<Trans>Snaps to increments of 5.</Trans>
|
||||
</p>
|
||||
<div className={styles.sliderRow}>
|
||||
<div className={styles.sliderContainer}>
|
||||
<Slider
|
||||
defaultValue={sliderValue3}
|
||||
factoryDefaultValue={50}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
step={5}
|
||||
onValueChange={(v) => setSliderValue3(Math.round(v))}
|
||||
onValueRender={(v) => `${Math.round(v)}%`}
|
||||
markers={[0, 25, 50, 75, 100]}
|
||||
onMarkerRender={(m) => `${m}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValue}>{sliderValue3}%</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Markers Below Slider</Trans>
|
||||
</SubsectionTitle>
|
||||
<p className={styles.descriptionSmall}>
|
||||
<Trans>Alternative marker positioning.</Trans>
|
||||
</p>
|
||||
<div className={styles.sliderRow}>
|
||||
<div className={styles.sliderContainer}>
|
||||
<Slider
|
||||
defaultValue={sliderValue4}
|
||||
factoryDefaultValue={75}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
markerPosition="below"
|
||||
onValueChange={(v) => setSliderValue4(Math.round(v))}
|
||||
onValueRender={(v) => `${Math.round(v)}%`}
|
||||
markers={[0, 25, 50, 75, 100]}
|
||||
onMarkerRender={(m) => `${m}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValue}>{sliderValue4}%</div>
|
||||
</div>
|
||||
|
||||
<SubsectionTitle>
|
||||
<Trans>Disabled Slider</Trans>
|
||||
</SubsectionTitle>
|
||||
<div className={styles.sliderRow}>
|
||||
<div className={styles.sliderContainer}>
|
||||
<Slider
|
||||
defaultValue={sliderValue5}
|
||||
factoryDefaultValue={60}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
disabled
|
||||
onValueRender={(v) => `${Math.round(v)}%`}
|
||||
markers={[0, 25, 50, 75, 100]}
|
||||
onMarkerRender={(m) => `${m}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValueDisabled}>{sliderValue5}%</div>
|
||||
</div>
|
||||
</SettingsTabSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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 {
|
||||
DotsThreeOutlineIcon,
|
||||
GearIcon,
|
||||
LinkSimpleIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ShareFatIcon,
|
||||
TrashIcon,
|
||||
WarningCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
import {MenuGroups} from '~/components/uikit/ContextMenu/MenuGroups';
|
||||
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
|
||||
import {MenuItemCheckbox} from '~/components/uikit/ContextMenu/MenuItemCheckbox';
|
||||
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
|
||||
import {MenuItemSlider} from '~/components/uikit/ContextMenu/MenuItemSlider';
|
||||
import {MenuItemSubmenu} from '~/components/uikit/ContextMenu/MenuItemSubmenu';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {ButtonsTab} from './ButtonsTab';
|
||||
import {IndicatorsTab} from './IndicatorsTab';
|
||||
import {InputsTab} from './InputsTab';
|
||||
import {MarkdownTab} from './MarkdownTab';
|
||||
import {OverlaysTab} from './OverlaysTab';
|
||||
import {SelectionsTab} from './SelectionsTab';
|
||||
|
||||
const ComponentGalleryTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const [primarySwitch, setPrimarySwitch] = React.useState(true);
|
||||
const [dangerSwitch, setDangerSwitch] = React.useState(false);
|
||||
const [selectValue, setSelectValue] = React.useState('opt1');
|
||||
const [selectValue2, setSelectValue2] = React.useState('size-md');
|
||||
const [sliderValue, setSliderValue] = React.useState(42);
|
||||
const [sliderValue2, setSliderValue2] = React.useState(75);
|
||||
const [sliderValue3, setSliderValue3] = React.useState(50);
|
||||
|
||||
const [sliderValue4, setSliderValue4] = React.useState(75);
|
||||
const [sliderValue5, setSliderValue5] = React.useState(60);
|
||||
const [color, setColor] = React.useState(0x3b82f6);
|
||||
const [color2, setColor2] = React.useState(0xff5733);
|
||||
const [radioValue, setRadioValue] = React.useState<'a' | 'b' | 'c'>('a');
|
||||
const [checkOne, setCheckOne] = React.useState(true);
|
||||
const [checkTwo, setCheckTwo] = React.useState(false);
|
||||
const [checkboxChecked, setCheckboxChecked] = React.useState(false);
|
||||
const [checkboxChecked2, setCheckboxChecked2] = React.useState(true);
|
||||
const [radioGroupValue, setRadioGroupValue] = React.useState<string>('option1');
|
||||
const [inputValue1, setInputValue1] = React.useState('');
|
||||
const [inputValue2, setInputValue2] = React.useState('');
|
||||
const [inputValue3, setInputValue3] = React.useState('');
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [emailValue, setEmailValue] = React.useState('');
|
||||
const [passwordValue, setPasswordValue] = React.useState('');
|
||||
const [textareaValue1, setTextareaValue1] = React.useState('');
|
||||
const [textareaValue2, setTextareaValue2] = React.useState('This is some example text in the textarea.');
|
||||
const [inlineEditValue, setInlineEditValue] = React.useState('EditableText');
|
||||
|
||||
const radioOptions: Array<RadioOption<string>> = [
|
||||
{value: 'option1', name: t`First Option`, desc: t`This is the first option description`},
|
||||
{value: 'option2', name: t`Second Option`, desc: t`This is the second option description`},
|
||||
{value: 'option3', name: t`Third Option`, desc: t`This is the third option description`},
|
||||
];
|
||||
|
||||
const openContextMenu = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
|
||||
<MenuGroups>
|
||||
<MenuGroup>
|
||||
<MenuItem icon={<GearIcon size={16} />} onClick={() => onClose()}>
|
||||
<Trans>Settings</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem icon={<ShareFatIcon size={16} />} onClick={() => onClose()}>
|
||||
<Trans>Share</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem icon={<LinkSimpleIcon size={16} weight="bold" />} onClick={() => onClose()}>
|
||||
<Trans>Copy Link</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkOne} onChange={setCheckOne}>
|
||||
<Trans>Enable Extra Option</Trans>
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkTwo} onChange={setCheckTwo}>
|
||||
<Trans>Enable Beta Feature</Trans>
|
||||
</MenuItemCheckbox>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'a'}
|
||||
onSelect={() => setRadioValue('a')}
|
||||
>
|
||||
<Trans>Mode A</Trans>
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'b'}
|
||||
onSelect={() => setRadioValue('b')}
|
||||
>
|
||||
<Trans>Mode B</Trans>
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={radioValue === 'c'}
|
||||
onSelect={() => setRadioValue('c')}
|
||||
>
|
||||
<Trans>Mode C</Trans>
|
||||
</MenuItemRadio>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemSlider
|
||||
label={t`Opacity`}
|
||||
value={sliderValue}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
onChange={(v: number) => setSliderValue(Math.round(v))}
|
||||
onFormat={(v: number) => `${Math.round(v)}%`}
|
||||
/>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemSubmenu
|
||||
label={t`More Actions`}
|
||||
icon={<DotsThreeOutlineIcon size={16} />}
|
||||
render={() => (
|
||||
<>
|
||||
<MenuItem onClick={() => onClose()}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onClose()}>
|
||||
<Trans>Archive</Trans>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<MenuItem icon={<TrashIcon size={16} />} danger onClick={() => onClose()}>
|
||||
<Trans>Delete</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
</MenuGroups>
|
||||
));
|
||||
},
|
||||
[checkOne, checkTwo, radioValue, sliderValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection id="buttons" title={t`Buttons`}>
|
||||
<ButtonsTab openContextMenu={openContextMenu} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="inputs" title={t`Inputs & Text`}>
|
||||
<InputsTab
|
||||
inputValue1={inputValue1}
|
||||
setInputValue1={setInputValue1}
|
||||
inputValue2={inputValue2}
|
||||
setInputValue2={setInputValue2}
|
||||
inputValue3={inputValue3}
|
||||
setInputValue3={setInputValue3}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
emailValue={emailValue}
|
||||
setEmailValue={setEmailValue}
|
||||
passwordValue={passwordValue}
|
||||
setPasswordValue={setPasswordValue}
|
||||
textareaValue1={textareaValue1}
|
||||
setTextareaValue1={setTextareaValue1}
|
||||
textareaValue2={textareaValue2}
|
||||
setTextareaValue2={setTextareaValue2}
|
||||
inlineEditValue={inlineEditValue}
|
||||
setInlineEditValue={setInlineEditValue}
|
||||
color={color}
|
||||
setColor={setColor}
|
||||
color2={color2}
|
||||
setColor2={setColor2}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="selections" title={t`Selections`}>
|
||||
<SelectionsTab
|
||||
selectValue={selectValue}
|
||||
setSelectValue={setSelectValue}
|
||||
selectValue2={selectValue2}
|
||||
setSelectValue2={setSelectValue2}
|
||||
primarySwitch={primarySwitch}
|
||||
setPrimarySwitch={setPrimarySwitch}
|
||||
dangerSwitch={dangerSwitch}
|
||||
setDangerSwitch={setDangerSwitch}
|
||||
checkboxChecked={checkboxChecked}
|
||||
setCheckboxChecked={setCheckboxChecked}
|
||||
checkboxChecked2={checkboxChecked2}
|
||||
setCheckboxChecked2={setCheckboxChecked2}
|
||||
radioGroupValue={radioGroupValue}
|
||||
setRadioGroupValue={setRadioGroupValue}
|
||||
radioOptions={radioOptions}
|
||||
sliderValue={sliderValue}
|
||||
setSliderValue={setSliderValue}
|
||||
sliderValue2={sliderValue2}
|
||||
setSliderValue2={setSliderValue2}
|
||||
sliderValue3={sliderValue3}
|
||||
setSliderValue3={setSliderValue3}
|
||||
sliderValue4={sliderValue4}
|
||||
setSliderValue4={setSliderValue4}
|
||||
sliderValue5={sliderValue5}
|
||||
setSliderValue5={setSliderValue5}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="overlays" title={t`Overlays & Menus`}>
|
||||
<OverlaysTab openContextMenu={openContextMenu} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="indicators" title={t`Indicators & Status`}>
|
||||
<IndicatorsTab />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="status"
|
||||
title={t`Status Slate`}
|
||||
description={t`A reusable component for empty states, errors, and status messages.`}
|
||||
>
|
||||
<StatusSlate
|
||||
Icon={WarningCircleIcon}
|
||||
title={<Trans>Lorem ipsum dolor sit amet</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua.
|
||||
</Trans>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Primary Action</Trans>,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
text: <Trans>Secondary Action</Trans>,
|
||||
onClick: () => {},
|
||||
variant: 'secondary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="markdown" title={t`Markdown`}>
|
||||
<MarkdownTab />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default ComponentGalleryTab;
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subsectionTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import styles from './shared.module.css';
|
||||
|
||||
export const SubsectionTitle = observer(({children}: {children: React.ReactNode}) => (
|
||||
<h4 className={styles.subsectionTitle}>{children}</h4>
|
||||
));
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export {default} from './DeveloperOptionsTab/index';
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.sliderContainer {
|
||||
margin-left: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sliderLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.labelDescription {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
|
||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import {Select} from '~/components/form/Select';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import styles from './AccountPremiumTab.module.css';
|
||||
import {
|
||||
applyPremiumScenarioOption,
|
||||
PREMIUM_SCENARIO_OPTIONS,
|
||||
type PremiumScenarioOption,
|
||||
} from './premiumScenarioOptions';
|
||||
|
||||
interface AccountPremiumTabContentProps {
|
||||
user: UserRecord;
|
||||
}
|
||||
|
||||
export const AccountPremiumTabContent: React.FC<AccountPremiumTabContentProps> = observer(({user}) => {
|
||||
const {t} = useLingui();
|
||||
const premiumTypeOptions = [
|
||||
{value: '', label: t`Use Actual Premium Type`},
|
||||
{value: UserPremiumTypes.NONE.toString(), label: t`None`},
|
||||
{value: UserPremiumTypes.SUBSCRIPTION.toString(), label: t`Subscription`},
|
||||
{value: UserPremiumTypes.LIFETIME.toString(), label: t`Lifetime`},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection title={<Trans>Account State Overrides</Trans>}>
|
||||
<Switch
|
||||
label={t`Email Verified Override`}
|
||||
value={DeveloperOptionsStore.emailVerifiedOverride ?? false}
|
||||
description={t`Override email verification status`}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption('emailVerifiedOverride', value ? true : null)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Unclaimed Account Override`}
|
||||
value={DeveloperOptionsStore.unclaimedAccountOverride ?? false}
|
||||
description={t`Override unclaimed account status`}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption('unclaimedAccountOverride', value ? true : null)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Unread Gift Inventory Override`}
|
||||
value={DeveloperOptionsStore.hasUnreadGiftInventoryOverride ?? false}
|
||||
description={t`Override unread gift inventory status`}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption('hasUnreadGiftInventoryOverride', value ? true : null);
|
||||
if (!value) DeveloperOptionsActionCreators.updateOption('unreadGiftInventoryCountOverride', null);
|
||||
}}
|
||||
/>
|
||||
{DeveloperOptionsStore.hasUnreadGiftInventoryOverride && (
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderLabel}>
|
||||
<span className={styles.labelText}>
|
||||
<Trans>Unread Gift Count</Trans>
|
||||
</span>
|
||||
<p className={styles.labelDescription}>
|
||||
<Trans>Set the number of unread gifts in inventory.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={DeveloperOptionsStore.unreadGiftInventoryCountOverride ?? 1}
|
||||
factoryDefaultValue={1}
|
||||
minValue={0}
|
||||
maxValue={99}
|
||||
step={1}
|
||||
markers={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 99]}
|
||||
stickToMarkers={false}
|
||||
onMarkerRender={(v) => `${v}`}
|
||||
onValueRender={(v) => <Plural value={v} one="# gift" other="# gifts" />}
|
||||
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('unreadGiftInventoryCountOverride', v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Premium Type Override</Trans>}>
|
||||
<Select
|
||||
label={t`Override Premium Type`}
|
||||
value={DeveloperOptionsStore.premiumTypeOverride?.toString() ?? ''}
|
||||
options={premiumTypeOptions}
|
||||
onChange={(value) => {
|
||||
const premiumTypeOverride = value === '' ? null : Number.parseInt(value, 10);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', premiumTypeOverride);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Backend Premium Override`}
|
||||
value={user.premiumEnabledOverride ?? false}
|
||||
description={t`Toggle premium_enabled_override on the backend`}
|
||||
onChange={async (value) => {
|
||||
await UserActionCreators.update({premium_enabled_override: value});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Has Ever Purchased Override`}
|
||||
value={DeveloperOptionsStore.hasEverPurchasedOverride ?? false}
|
||||
description={t`Simulates having a Stripe customer ID (for testing purchase history access)`}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', value ? true : null)
|
||||
}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Premium Subscription Scenarios</Trans>}>
|
||||
<Select<PremiumScenarioOption>
|
||||
label={t`Test Subscription State`}
|
||||
value="none"
|
||||
options={PREMIUM_SCENARIO_OPTIONS.map(({value, label}) => ({
|
||||
value,
|
||||
label: t(label),
|
||||
}))}
|
||||
onChange={applyPremiumScenarioOption}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.toggleGroup {
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.toggleGroupFirst {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.toggleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import styles from './GeneralTab.module.css';
|
||||
import {getToggleGroups} from './shared';
|
||||
|
||||
export const GeneralTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const toggleGroups = getToggleGroups();
|
||||
|
||||
return (
|
||||
<>
|
||||
{toggleGroups.map((group, gi) => (
|
||||
<div
|
||||
key={group.title.id ?? `toggle-group-${gi}`}
|
||||
className={gi > 0 ? styles.toggleGroup : styles.toggleGroupFirst}
|
||||
>
|
||||
<div className={styles.groupTitle}>{t(group.title)}</div>
|
||||
<div className={styles.toggleList}>
|
||||
{group.items.map(({key, label, description}) => (
|
||||
<Switch
|
||||
key={String(key)}
|
||||
label={t(label)}
|
||||
description={description ? t(description) : undefined}
|
||||
value={Boolean(DeveloperOptionsStore[key])}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption(key, value);
|
||||
if (key === 'selfHostedModeOverride') {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import NagbarStore from '~/stores/NagbarStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {AccountPremiumTabContent} from './AccountPremiumTab';
|
||||
import {GeneralTabContent} from './GeneralTab';
|
||||
import styles from './Inline.module.css';
|
||||
import {MockingTabContent} from './MockingTab';
|
||||
import {NagbarsTabContent} from './NagbarsTab';
|
||||
import {ToolsTabContent} from './ToolsTab';
|
||||
|
||||
export const DeveloperOptionsInlineTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const socket = ConnectionStore.socket;
|
||||
const nagbarState = NagbarStore;
|
||||
const user = UserStore.currentUser;
|
||||
|
||||
if (!(user && socket)) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SettingsSection id="dev-general" title={t`General`}>
|
||||
<GeneralTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="dev-account-premium" title={t`Account & Premium`}>
|
||||
<AccountPremiumTabContent user={user} />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="dev-mocking" title={t`Mocking`}>
|
||||
<MockingTabContent />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="dev-nagbars" title={t`Nagbars`}>
|
||||
<NagbarsTabContent nagbarState={nagbarState} />
|
||||
</SettingsSection>
|
||||
<SettingsSection id="dev-tools" title={t`Tools`}>
|
||||
<ToolsTabContent socket={socket} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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/>.
|
||||
*/
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sliderContainer {
|
||||
margin-left: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sliderLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.labelDescription {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {Select} from '~/components/form/Select';
|
||||
import {Switch} from '~/components/form/Switch';
|
||||
import RequiredActionModal from '~/components/modals/RequiredActionModal';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Slider} from '~/components/uikit/Slider';
|
||||
import type {DeveloperOptionsState} from '~/stores/DeveloperOptionsStore';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import styles from './MockingTab.module.css';
|
||||
|
||||
export const MockingTabContent: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const handleClearAttachmentMocks = () => {
|
||||
DeveloperOptionsActionCreators.clearAllAttachmentMocks();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTabSection title={<Trans>Verification Barriers</Trans>}>
|
||||
<Select
|
||||
label={t`Mock Verification Barrier`}
|
||||
value={DeveloperOptionsStore.mockVerificationBarrier}
|
||||
options={[
|
||||
{value: 'none', label: t`None (Normal Behavior)`},
|
||||
{value: 'unclaimed_account', label: t`Unclaimed Account`},
|
||||
{value: 'unverified_email', label: t`Unverified Email`},
|
||||
{value: 'account_too_new', label: t`Account Too New`},
|
||||
{value: 'not_member_long', label: t`Not Member Long Enough`},
|
||||
{value: 'no_phone', label: t`No Phone Number`},
|
||||
{value: 'send_message_disabled', label: t`Send Message Disabled`},
|
||||
]}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockVerificationBarrier',
|
||||
value as DeveloperOptionsState['mockVerificationBarrier'],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{(DeveloperOptionsStore.mockVerificationBarrier === 'account_too_new' ||
|
||||
DeveloperOptionsStore.mockVerificationBarrier === 'not_member_long') && (
|
||||
<Select<string>
|
||||
label={t`Countdown Timer`}
|
||||
value={DeveloperOptionsStore.mockBarrierTimeRemaining?.toString() ?? '300000'}
|
||||
options={[
|
||||
{value: '0', label: t`No Timer`},
|
||||
{value: '10000', label: t`10 seconds`},
|
||||
{value: '30000', label: t`30 seconds`},
|
||||
{value: '60000', label: t`1 minute`},
|
||||
{value: '120000', label: t`2 minutes`},
|
||||
{value: '300000', label: t`5 minutes`},
|
||||
{value: '600000', label: t`10 minutes`},
|
||||
]}
|
||||
onChange={(v) =>
|
||||
DeveloperOptionsActionCreators.updateOption('mockBarrierTimeRemaining', Number.parseInt(v, 10))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Channel Permissions</Trans>}>
|
||||
<Switch
|
||||
label={t`Force No SEND_MESSAGES Permission`}
|
||||
value={DeveloperOptionsStore.forceNoSendMessages}
|
||||
description={t`Removes SEND_MESSAGES permission in all channels (disables textarea and all buttons)`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('forceNoSendMessages', value)}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Force No ATTACH_FILES Permission`}
|
||||
value={DeveloperOptionsStore.forceNoAttachFiles}
|
||||
description={t`Removes ATTACH_FILES permission in all channels (hides upload button)`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('forceNoAttachFiles', value)}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Attachments</Trans>}>
|
||||
<Button variant="secondary" onClick={handleClearAttachmentMocks}>
|
||||
<Trans>Clear attachment mocks</Trans>
|
||||
</Button>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Voice Calls</Trans>}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => DeveloperOptionsActionCreators.triggerMockIncomingCall()}
|
||||
disabled={!UserStore.currentUser}
|
||||
>
|
||||
<Trans>Trigger Mock Incoming Call</Trans>
|
||||
</Button>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Slowmode</Trans>}>
|
||||
<Switch
|
||||
label={t`Force Slowmode Active`}
|
||||
value={DeveloperOptionsStore.mockSlowmodeActive}
|
||||
description={t`Forces slowmode to be active in all channels`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockSlowmodeActive', value)}
|
||||
/>
|
||||
{DeveloperOptionsStore.mockSlowmodeActive && (
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderLabel}>
|
||||
<span className={styles.labelText}>
|
||||
<Trans>Slowmode Time Remaining (ms)</Trans>
|
||||
</span>
|
||||
<p className={styles.labelDescription}>
|
||||
<Trans>Set how much time remains before the user can send another message.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={DeveloperOptionsStore.mockSlowmodeRemaining}
|
||||
factoryDefaultValue={10000}
|
||||
minValue={0}
|
||||
maxValue={60000}
|
||||
step={1000}
|
||||
markers={[0, 5000, 10000, 15000, 20000, 30000, 45000, 60000]}
|
||||
stickToMarkers={false}
|
||||
onMarkerRender={(v) => `${v / 1000}s`}
|
||||
onValueRender={(v) => <Trans>{Math.floor(v / 1000)} seconds</Trans>}
|
||||
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('mockSlowmodeRemaining', v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection
|
||||
title={
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.headerTitle}>
|
||||
<Trans>Visionary</Trans>
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
small={true}
|
||||
onClick={() => {
|
||||
DeveloperOptionsActionCreators.updateOption('mockVisionarySoldOut', false);
|
||||
DeveloperOptionsActionCreators.updateOption('mockVisionaryRemaining', null);
|
||||
}}
|
||||
>
|
||||
<Trans>Reset to Real Data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
label={t`Mock Visionary Sold Out`}
|
||||
value={DeveloperOptionsStore.mockVisionarySoldOut}
|
||||
description={t`Force Visionary to appear sold out`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockVisionarySoldOut', value)}
|
||||
/>
|
||||
{!DeveloperOptionsStore.mockVisionarySoldOut && (
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderLabel}>
|
||||
<span className={styles.labelText}>
|
||||
<Trans>Mock Visionary Remaining</Trans>
|
||||
</span>
|
||||
<p className={styles.labelDescription}>
|
||||
<Trans>Set a fixed remaining count for Visionary availability.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={DeveloperOptionsStore.mockVisionaryRemaining ?? 100}
|
||||
factoryDefaultValue={100}
|
||||
minValue={0}
|
||||
maxValue={1000}
|
||||
step={10}
|
||||
markers={[0, 50, 100, 250, 500, 750, 1000]}
|
||||
stickToMarkers={false}
|
||||
onMarkerRender={(v) => `${v}`}
|
||||
onValueRender={(v) => <Trans>{v} remaining</Trans>}
|
||||
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('mockVisionaryRemaining', v)}
|
||||
/>
|
||||
<div className={styles.note}>
|
||||
<Trans>Set to 0 to simulate sold out.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Required Actions</Trans>}>
|
||||
<Select
|
||||
label={t`Mock Variant`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsMode}
|
||||
description={t`Choose which variant to preview`}
|
||||
options={[
|
||||
{value: 'email', label: t`Email`},
|
||||
{value: 'phone', label: t`Phone`},
|
||||
{value: 'email_or_phone', label: t`Email or Phone`},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockRequiredActionsMode',
|
||||
value as DeveloperOptionsState['mockRequiredActionsMode'],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <RequiredActionModal mock={true} />),
|
||||
'required-actions-mock',
|
||||
)
|
||||
}
|
||||
disabled={!UserStore.currentUser}
|
||||
>
|
||||
<Trans>Open Overlay</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{DeveloperOptionsStore.mockRequiredActionsMode === 'email_or_phone' && (
|
||||
<Select
|
||||
label={t`Default Tab`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsSelectedTab}
|
||||
description={t`Which tab is selected when the overlay opens`}
|
||||
options={[
|
||||
{value: 'email', label: t`Email`},
|
||||
{value: 'phone', label: t`Phone`},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockRequiredActionsSelectedTab',
|
||||
value as DeveloperOptionsState['mockRequiredActionsSelectedTab'],
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(DeveloperOptionsStore.mockRequiredActionsMode === 'phone' ||
|
||||
(DeveloperOptionsStore.mockRequiredActionsMode === 'email_or_phone' &&
|
||||
DeveloperOptionsStore.mockRequiredActionsSelectedTab === 'phone')) && (
|
||||
<Select
|
||||
label={t`Phone Step`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsPhoneStep}
|
||||
description={t`Pick which phone step to show`}
|
||||
options={[
|
||||
{value: 'phone', label: t`Enter Phone`},
|
||||
{value: 'code', label: t`Enter Code`},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockRequiredActionsPhoneStep',
|
||||
value as DeveloperOptionsState['mockRequiredActionsPhoneStep'],
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
label={t`Use Reverification Text`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsReverify}
|
||||
description={t`Swap text to reverify variants`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockRequiredActionsReverify', value)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t`Resend Button Loading`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsResending}
|
||||
description={t`Force the email resend button into a loading state`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockRequiredActionsResending', value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t`Resend Outcome`}
|
||||
value={DeveloperOptionsStore.mockRequiredActionsResendOutcome}
|
||||
description={t`Toast shown when clicking resend in mock mode`}
|
||||
options={[
|
||||
{value: 'success', label: t`Success`},
|
||||
{value: 'rate_limited', label: t`Rate Limited`},
|
||||
{value: 'server_error', label: t`Server Error`},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockRequiredActionsResendOutcome',
|
||||
value as DeveloperOptionsState['mockRequiredActionsResendOutcome'],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>NSFW Gates</Trans>}>
|
||||
<Select
|
||||
label={t`Mock NSFW Channel Gate Reason`}
|
||||
value={DeveloperOptionsStore.mockNSFWGateReason}
|
||||
description={t`Mock gate reason for blocking entire NSFW channels`}
|
||||
options={[
|
||||
{value: 'none', label: t`None (Normal Behavior)`},
|
||||
{value: 'geo_restricted', label: t`Geo Restricted`},
|
||||
{value: 'age_restricted', label: t`Age Restricted`},
|
||||
{value: 'consent_required', label: t`Consent Required`},
|
||||
]}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockNSFWGateReason',
|
||||
value as DeveloperOptionsState['mockNSFWGateReason'],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t`Mock NSFW Media Gate Reason`}
|
||||
value={DeveloperOptionsStore.mockNSFWMediaGateReason}
|
||||
description={t`Forces all media to NSFW and shows the selected blur overlay error state`}
|
||||
options={[
|
||||
{value: 'none', label: t`None (Normal Behavior)`},
|
||||
{value: 'geo_restricted', label: t`Geo Restricted`},
|
||||
{value: 'age_restricted', label: t`Age Restricted`},
|
||||
]}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption(
|
||||
'mockNSFWMediaGateReason',
|
||||
value as DeveloperOptionsState['mockNSFWMediaGateReason'],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Mock Geo Block Overlay`}
|
||||
value={DeveloperOptionsStore.mockGeoBlocked}
|
||||
description={t`Show the geo-blocked overlay (dismissible)`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockGeoBlocked', value)}
|
||||
/>
|
||||
</SettingsTabSection>
|
||||
|
||||
<SettingsTabSection title={<Trans>Gift Inventory</Trans>}>
|
||||
<Switch
|
||||
label={t`Mock Gift Inventory`}
|
||||
value={DeveloperOptionsStore.mockGiftInventory ?? false}
|
||||
description={t`Show a mock gift code in your gift inventory for testing`}
|
||||
onChange={(value) => {
|
||||
DeveloperOptionsActionCreators.updateOption('mockGiftInventory', value ? true : null);
|
||||
if (!value) {
|
||||
DeveloperOptionsActionCreators.updateOption('mockGiftDurationMonths', 12);
|
||||
DeveloperOptionsActionCreators.updateOption('mockGiftRedeemed', null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{DeveloperOptionsStore.mockGiftInventory && (
|
||||
<>
|
||||
<Select<string>
|
||||
label={t`Gift Duration`}
|
||||
value={DeveloperOptionsStore.mockGiftDurationMonths?.toString() ?? '12'}
|
||||
description={t`How many months of Plutonium the mock gift provides`}
|
||||
options={[
|
||||
{value: '1', label: t`1 Month`},
|
||||
{value: '3', label: t`3 Months`},
|
||||
{value: '6', label: t`6 Months`},
|
||||
{value: '12', label: t`12 Months (1 Year)`},
|
||||
{value: '0', label: t`Lifetime`},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
DeveloperOptionsActionCreators.updateOption('mockGiftDurationMonths', Number.parseInt(value, 10))
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label={t`Mark as Redeemed`}
|
||||
value={DeveloperOptionsStore.mockGiftRedeemed ?? false}
|
||||
description={t`Show the gift as already redeemed`}
|
||||
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockGiftRedeemed', value ? true : null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsTabSection>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.nagbarList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nagbarItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nagbarInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nagbarLabel {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.nagbarStatus {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {NagbarStore, NagbarToggleKey} from '~/stores/NagbarStore';
|
||||
import styles from './NagbarsTab.module.css';
|
||||
import {getNagbarControls, type NagbarControlDefinition} from './nagbarControls';
|
||||
|
||||
interface NagbarsTabContentProps {
|
||||
nagbarState: NagbarStore;
|
||||
}
|
||||
|
||||
const NagbarRow: React.FC<{
|
||||
control: NagbarControlDefinition;
|
||||
nagbarState: NagbarStore;
|
||||
}> = observer(({control, nagbarState}) => {
|
||||
const {t} = useLingui();
|
||||
const getFlag = (key: NagbarToggleKey): boolean => Boolean(nagbarState[key]);
|
||||
const useActualDisabled = control.useActualDisabled?.(nagbarState) ?? control.resetKeys.every((key) => !getFlag(key));
|
||||
const forceShowDisabled = control.forceShowDisabled?.(nagbarState) ?? getFlag(control.forceKey);
|
||||
const forceHideDisabled = control.forceHideDisabled?.(nagbarState) ?? getFlag(control.forceHideKey);
|
||||
|
||||
const handleUseActual = () => {
|
||||
control.resetKeys.forEach((key) => NagbarActionCreators.resetNagbar(key));
|
||||
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, false);
|
||||
};
|
||||
|
||||
const handleForceShow = () => {
|
||||
NagbarActionCreators.dismissNagbar(control.forceKey);
|
||||
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, false);
|
||||
};
|
||||
|
||||
const handleForceHide = () => {
|
||||
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, true);
|
||||
NagbarActionCreators.resetNagbar(control.forceKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.nagbarItem}>
|
||||
<div className={styles.nagbarInfo}>
|
||||
<span className={styles.nagbarLabel}>{control.label}</span>
|
||||
<span className={styles.nagbarStatus}>{control.status(nagbarState)}</span>
|
||||
</div>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button onClick={handleUseActual} disabled={useActualDisabled}>
|
||||
{t`Use Actual`}
|
||||
</Button>
|
||||
<Button onClick={handleForceShow} disabled={forceShowDisabled}>
|
||||
{t`Force Show`}
|
||||
</Button>
|
||||
<Button onClick={handleForceHide} disabled={forceHideDisabled}>
|
||||
{t`Force Hide`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const NagbarsTabContent: React.FC<NagbarsTabContentProps> = observer(({nagbarState}) => {
|
||||
const {t} = useLingui();
|
||||
const nagbarControls = getNagbarControls(t);
|
||||
|
||||
return (
|
||||
<div className={styles.nagbarList}>
|
||||
{nagbarControls.map((control) => (
|
||||
<NagbarRow key={control.key} control={control} nagbarState={nagbarState} />
|
||||
))}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
onClick={() => NagbarActionCreators.resetAllNagbars()}
|
||||
variant="secondary"
|
||||
>{t`Reset All Nagbars`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useState} from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
|
||||
import {CaptchaModal} from '~/components/modals/CaptchaModal';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {GatewaySocket} from '~/lib/GatewaySocket';
|
||||
import NewDeviceMonitoringStore from '~/stores/NewDeviceMonitoringStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import styles from './ToolsTab.module.css';
|
||||
|
||||
interface ToolsTabContentProps {
|
||||
socket: GatewaySocket;
|
||||
}
|
||||
|
||||
export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket}) => {
|
||||
const {t} = useLingui();
|
||||
const [isTestingBulkDelete, setIsTestingBulkDelete] = useState(false);
|
||||
const [shouldCrash, setShouldCrash] = useState(false);
|
||||
|
||||
const handleTestBulkDelete = useCallback(async () => {
|
||||
setIsTestingBulkDelete(true);
|
||||
try {
|
||||
await testBulkDeleteAllMessages();
|
||||
} finally {
|
||||
setIsTestingBulkDelete(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenCaptchaModal = useCallback(() => {
|
||||
ModalActionCreators.push(
|
||||
ModalActionCreators.modal(() => (
|
||||
<CaptchaModal
|
||||
closeOnVerify={false}
|
||||
onVerify={(token, captchaType) => {
|
||||
console.debug('Captcha solved in Developer Options', {token, captchaType});
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.debug('Captcha cancelled in Developer Options');
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleOpenClaimAccountModal = useCallback(() => {
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
|
||||
}, []);
|
||||
|
||||
if (shouldCrash) {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button onClick={() => socket.reset()}>{t`Reset Socket`}</Button>
|
||||
<Button onClick={() => socket.simulateNetworkDisconnect()}>{t`Disconnect Socket`}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void MediaEngineStore.moveToAfkChannel();
|
||||
}}
|
||||
>
|
||||
{t`Force Move to AFK Channel`}
|
||||
</Button>
|
||||
<Button onClick={() => NewDeviceMonitoringStore.showTestModal()}>{t`Show New Device Modal`}</Button>
|
||||
<Button onClick={handleOpenCaptchaModal}>{t`Open Captcha Modal`}</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <KeyboardModeIntroModal />));
|
||||
}}
|
||||
>
|
||||
{t`Show Keyboard Mode Intro`}
|
||||
</Button>
|
||||
<Button onClick={handleOpenClaimAccountModal}>{t`Open Claim Account Modal`}</Button>
|
||||
<Button onClick={() => void handleTestBulkDelete()} submitting={isTestingBulkDelete} variant="danger-primary">
|
||||
{t`Test Bulk Delete (60s)`}
|
||||
</Button>
|
||||
<Button onClick={() => setShouldCrash(true)} variant="danger-primary">
|
||||
{t`Trigger React Crash`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1rem;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fontName {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.langCode {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.fontFamily {
|
||||
font-family: monospace;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sampleText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weightCard {
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.weightHeader {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.weightLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.weightValue {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.weightItalic {
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.scaleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scaleItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.scaleSize {
|
||||
width: 5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scaleSizeText {
|
||||
font-family: monospace;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.scaleLabel {
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scaleLabelText {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scaleSample {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.styleGrid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.styleGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.styleGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.styleCard {
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.styleLabel {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.codeGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.codeGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.codeCard {
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.codeTitle {
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codeLines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.multilingualCard {
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.multilingualList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.multilingualItem {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import styles from './TypographyTab.module.css';
|
||||
|
||||
const fontSamples = [
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans',
|
||||
name: 'IBM Plex Sans',
|
||||
sample: 'The quick brown fox jumps over the lazy dog',
|
||||
lang: 'en',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans JP',
|
||||
name: 'IBM Plex Sans Japanese',
|
||||
sample: 'これは日本語のサンプルテキストです',
|
||||
lang: 'ja',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans KR',
|
||||
name: 'IBM Plex Sans Korean',
|
||||
sample: '이것은 한국어 샘플 텍스트입니다',
|
||||
lang: 'ko',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans SC',
|
||||
name: 'IBM Plex Sans Simplified Chinese',
|
||||
sample: '这是简体中文的示例文本',
|
||||
lang: 'zh-CN',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans TC',
|
||||
name: 'IBM Plex Sans Traditional Chinese',
|
||||
sample: '這是繁體中文的示例文本',
|
||||
lang: 'zh-TW',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans Arabic',
|
||||
name: 'IBM Plex Sans Arabic',
|
||||
sample: 'هذه عينة نصية باللغة العربية',
|
||||
lang: 'ar',
|
||||
rtl: true,
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans Hebrew',
|
||||
name: 'IBM Plex Sans Hebrew',
|
||||
sample: 'זוהי דוגמה לטקסט בעברית',
|
||||
lang: 'he',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans Devanagari',
|
||||
name: 'IBM Plex Sans Devanagari',
|
||||
sample: 'यह हिंदी का नमूना पाठ है',
|
||||
lang: 'hi',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans Thai',
|
||||
name: 'IBM Plex Sans Thai',
|
||||
sample: 'นี่คือข้อความตัวอย่างภาษาไทย',
|
||||
lang: 'th',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Sans Thai Looped',
|
||||
name: 'IBM Plex Sans Thai Looped',
|
||||
sample: 'นี่คือข้อความตัวอย่างภาษาไทยแบบ Loop',
|
||||
lang: 'th',
|
||||
},
|
||||
{
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
name: 'IBM Plex Mono',
|
||||
sample: 'const hello = "World"; function example() { return true; }',
|
||||
lang: 'en',
|
||||
mono: true,
|
||||
},
|
||||
];
|
||||
|
||||
const weightExamples = [
|
||||
{weight: 100, label: 'Thin', text: 'Delicate and light typography'},
|
||||
{weight: 200, label: 'Extra Light', text: 'Gentle and airy text display'},
|
||||
{weight: 300, label: 'Light', text: 'Soft and easy reading experience'},
|
||||
{weight: 400, label: 'Regular', text: 'Perfect for everyday content'},
|
||||
{weight: 450, label: 'Text', text: 'Optimized for longer reading passages'},
|
||||
{weight: 500, label: 'Medium', text: 'Slightly bolder emphasis'},
|
||||
{weight: 600, label: 'Semi Bold', text: 'Strong visual hierarchy'},
|
||||
{weight: 700, label: 'Bold', text: 'Powerful and attention-grabbing'},
|
||||
];
|
||||
|
||||
const scaleExamples = [
|
||||
{size: '12px', label: 'Caption', weight: 400},
|
||||
{size: '14px', label: 'Small', weight: 400},
|
||||
{size: '16px', label: 'Body', weight: 400},
|
||||
{size: '18px', label: 'Large', weight: 500},
|
||||
{size: '20px', label: 'Subtitle', weight: 500},
|
||||
{size: '24px', label: 'Heading', weight: 600},
|
||||
{size: '30px', label: 'Title', weight: 700},
|
||||
{size: '36px', label: 'Display', weight: 700},
|
||||
];
|
||||
|
||||
const contrastExamples = [
|
||||
{weight: 400, size: '16px', style: 'Normal'},
|
||||
{weight: 400, size: '16px', style: 'Italic'},
|
||||
{weight: 600, size: '16px', style: 'Semi Bold'},
|
||||
{weight: 600, size: '18px', style: 'Semi Bold Large'},
|
||||
{weight: 700, size: '20px', style: 'Bold Heading'},
|
||||
{weight: 700, size: '24px', style: 'Bold Title'},
|
||||
];
|
||||
|
||||
export const TypographyTabContent: React.FC = observer(() => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.heading}>
|
||||
<Trans>Typography Showcase</Trans>
|
||||
</h2>
|
||||
<p className={styles.description}>
|
||||
<Trans>
|
||||
Preview all available fonts, weights, and styles across different languages supported by Fluxer.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Language Support</Trans>
|
||||
</h3>
|
||||
<div className={styles.grid}>
|
||||
{fontSamples.map((font) => (
|
||||
<div key={font.fontFamily} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.cardInfo}>
|
||||
<span className={styles.fontName}>{font.name}</span>
|
||||
<span className={styles.langCode}>{font.lang.toUpperCase()}</span>
|
||||
</div>
|
||||
<span className={styles.fontFamily}>{font.fontFamily}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.sampleText}
|
||||
style={{
|
||||
fontFamily: font.mono ? 'var(--font-mono)' : `"${font.fontFamily}", var(--font-sans)`,
|
||||
fontSize: '16px',
|
||||
textAlign: font.rtl ? 'right' : 'left',
|
||||
}}
|
||||
lang={font.lang}
|
||||
dir={font.rtl ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{font.sample}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Font Weights</Trans>
|
||||
</h3>
|
||||
<div className={styles.codeGrid}>
|
||||
{weightExamples.map((example) => (
|
||||
<div key={example.weight} className={styles.weightCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.weightLabel}>{example.label}</span>
|
||||
<span className={styles.weightValue}>{example.weight}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: example.weight,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{example.text}
|
||||
</div>
|
||||
<div
|
||||
className={styles.weightItalic}
|
||||
style={{
|
||||
fontWeight: example.weight,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Italic style demonstration
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Type Scale</Trans>
|
||||
</h3>
|
||||
<div className={styles.scaleList}>
|
||||
{scaleExamples.map((example) => (
|
||||
<div key={example.size} className={styles.scaleItem}>
|
||||
<div className={styles.scaleSize}>
|
||||
<span className={styles.fontFamily}>{example.size}</span>
|
||||
</div>
|
||||
<div className={styles.scaleLabel}>
|
||||
<span className={styles.scaleLabelText}>{example.label}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.scaleSample}
|
||||
style={{
|
||||
fontSize: example.size,
|
||||
fontWeight: example.weight,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
Typography scale demonstration
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Style Variations</Trans>
|
||||
</h3>
|
||||
<div className={styles.grid}>
|
||||
{contrastExamples.map((example, index) => (
|
||||
<div key={index} className={styles.weightCard}>
|
||||
<div className={styles.styleLabel}>{example.style}</div>
|
||||
<div
|
||||
className={example.style.includes('Italic') ? styles.italic : ''}
|
||||
style={{
|
||||
fontSize: example.size,
|
||||
fontWeight: example.weight,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
This text demonstrates {example.style.toLowerCase()} styling
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Code & Monospace</Trans>
|
||||
</h3>
|
||||
<div className={styles.codeGrid}>
|
||||
<div className={styles.weightCard}>
|
||||
<div className={styles.codeTitle}>Light Code</div>
|
||||
<div
|
||||
className={styles.codeLines}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 300,
|
||||
}}
|
||||
>
|
||||
<div>const example = "light";</div>
|
||||
<div>function demo() {'{'}</div>
|
||||
<div> return true;</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.weightCard}>
|
||||
<div className={styles.codeTitle}>Regular Code</div>
|
||||
<div
|
||||
className={styles.codeLines}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<div>const example = "regular";</div>
|
||||
<div>function demo() {'{'}</div>
|
||||
<div> return true;</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.weightCard}>
|
||||
<div className={styles.codeTitle}>Medium Code</div>
|
||||
<div
|
||||
className={styles.codeLines}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div>const example = "medium";</div>
|
||||
<div>function demo() {'{'}</div>
|
||||
<div> return true;</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.weightCard}>
|
||||
<div className={styles.codeTitle}>Bold Code</div>
|
||||
<div
|
||||
className={styles.codeLines}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<div>const example = "bold";</div>
|
||||
<div>function demo() {'{'}</div>
|
||||
<div> return true;</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={styles.subheading}>
|
||||
<Trans>Multilingual Content</Trans>
|
||||
</h3>
|
||||
<div className={styles.multilingualCard}>
|
||||
<div className={styles.multilingualList} style={{fontFamily: 'var(--font-sans)', lineHeight: 1.6}}>
|
||||
<div className={styles.multilingualItem}>
|
||||
<strong>English:</strong> Welcome to Fluxer's typography showcase
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="ja">
|
||||
<strong>日本語:</strong> フラクサーのタイポグラフィショーケースへようこそ
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="ko">
|
||||
<strong>한국어:</strong> Fluxer의 타이포그래피 쇼케이스에 오신 것을 환영합니다
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="zh-CN">
|
||||
<strong>简体中文:</strong> 欢迎来到 Fluxer 的字体展示
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="zh-TW">
|
||||
<strong>繁體中文:</strong> 歡迎來到 Fluxer 的字體展示
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="ar" dir="rtl">
|
||||
<strong>العربية:</strong> مرحباً بك في عرض طباعة Fluxer
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="he">
|
||||
<strong>עברית:</strong> ברוכים הבאים לתצוגת הטיפוגרפיה של Fluxer
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="hi">
|
||||
<strong>हिंदी:</strong> Fluxer के टाइपोग्राफी शोकेस में आपका स्वागत है
|
||||
</div>
|
||||
<div className={styles.multilingualItem} lang="th">
|
||||
<strong>ไทย:</strong> ยินดีต้อนรับสู่การจัดแสดงพิมพ์ของ Fluxer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
|
||||
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import NagbarStore from '~/stores/NagbarStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {AccountPremiumTabContent} from './AccountPremiumTab';
|
||||
import {GeneralTabContent} from './GeneralTab';
|
||||
import {MockingTabContent} from './MockingTab';
|
||||
import {NagbarsTabContent} from './NagbarsTab';
|
||||
import {ToolsTabContent} from './ToolsTab';
|
||||
import {TypographyTabContent} from './TypographyTab';
|
||||
|
||||
const DeveloperOptionsTab: React.FC = observer(() => {
|
||||
const socket = ConnectionStore.socket;
|
||||
const nagbarState = NagbarStore;
|
||||
const user = UserStore.currentUser;
|
||||
|
||||
if (!(user && socket)) return null;
|
||||
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<SettingsSection id="general" title={<Trans>General</Trans>}>
|
||||
<GeneralTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="account_premium" title={<Trans>Account & Premium</Trans>}>
|
||||
<AccountPremiumTabContent user={user} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="mocking" title={<Trans>Mocking</Trans>}>
|
||||
<MockingTabContent />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="nagbars" title={<Trans>Nagbars</Trans>}>
|
||||
<NagbarsTabContent nagbarState={nagbarState} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="tools" title={<Trans>Tools</Trans>}>
|
||||
<ToolsTabContent socket={socket} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="typography" title={<Trans>Typography</Trans>}>
|
||||
<TypographyTabContent />
|
||||
</SettingsSection>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default DeveloperOptionsTab;
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import type {NagbarStore, NagbarToggleKey} from '~/stores/NagbarStore';
|
||||
|
||||
export type DismissibleNagbarKey = NagbarToggleKey;
|
||||
|
||||
export interface NagbarControlDefinition {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
forceKey: DismissibleNagbarKey;
|
||||
forceHideKey: NagbarToggleKey;
|
||||
resetKeys: Array<DismissibleNagbarKey>;
|
||||
status: (state: NagbarStore) => string;
|
||||
useActualDisabled?: (state: NagbarStore) => boolean;
|
||||
forceShowDisabled?: (state: NagbarStore) => boolean;
|
||||
forceHideDisabled?: (state: NagbarStore) => boolean;
|
||||
}
|
||||
|
||||
const FORCE_ENABLED_DESCRIPTOR = msg`Force enabled`;
|
||||
const FORCE_DISABLED_DESCRIPTOR = msg`Force disabled`;
|
||||
const USING_ACTUAL_ACCOUNT_STATE_DESCRIPTOR = msg`Using actual account state`;
|
||||
const USING_ACTUAL_VERIFICATION_STATE_DESCRIPTOR = msg`Using actual verification state`;
|
||||
const CURRENTLY_DISMISSED_DESCRIPTOR = msg`Currently dismissed`;
|
||||
const CURRENTLY_SHOWING_DESCRIPTOR = msg`Currently showing`;
|
||||
const USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR = msg`Using actual premium state`;
|
||||
const USING_ACTUAL_GIFT_INVENTORY_STATE_DESCRIPTOR = msg`Using actual gift inventory state`;
|
||||
const USING_ACTUAL_STATE_DESCRIPTOR = msg`Using actual state`;
|
||||
|
||||
export const getNagbarControls = (t: (message: MessageDescriptor) => string): Array<NagbarControlDefinition> => [
|
||||
{
|
||||
key: 'forceUnclaimedAccount',
|
||||
label: <Trans>Unclaimed Account Nagbar</Trans>,
|
||||
forceKey: 'forceUnclaimedAccount',
|
||||
forceHideKey: 'forceHideUnclaimedAccount',
|
||||
resetKeys: ['forceUnclaimedAccount'],
|
||||
status: (state) =>
|
||||
state.forceUnclaimedAccount
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHideUnclaimedAccount
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_ACCOUNT_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) => !state.forceUnclaimedAccount && !state.forceHideUnclaimedAccount,
|
||||
forceShowDisabled: (state) => state.forceUnclaimedAccount,
|
||||
forceHideDisabled: (state) => state.forceHideUnclaimedAccount,
|
||||
},
|
||||
{
|
||||
key: 'forceEmailVerification',
|
||||
label: <Trans>Email Verification Nagbar</Trans>,
|
||||
forceKey: 'forceEmailVerification',
|
||||
forceHideKey: 'forceHideEmailVerification',
|
||||
resetKeys: ['forceEmailVerification'],
|
||||
status: (state) =>
|
||||
state.forceEmailVerification
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHideEmailVerification
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_VERIFICATION_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) => !state.forceEmailVerification && !state.forceHideEmailVerification,
|
||||
forceShowDisabled: (state) => state.forceEmailVerification,
|
||||
forceHideDisabled: (state) => state.forceHideEmailVerification,
|
||||
},
|
||||
{
|
||||
key: 'forceDesktopNotification',
|
||||
label: <Trans>Desktop Notification Nagbar</Trans>,
|
||||
forceKey: 'forceDesktopNotification',
|
||||
forceHideKey: 'forceHideDesktopNotification',
|
||||
resetKeys: ['forceDesktopNotification', 'desktopNotificationDismissed'],
|
||||
status: (state) =>
|
||||
state.forceDesktopNotification
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHideDesktopNotification
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: state.desktopNotificationDismissed
|
||||
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
|
||||
: t(CURRENTLY_SHOWING_DESCRIPTOR),
|
||||
useActualDisabled: (state) =>
|
||||
!state.forceDesktopNotification && !state.desktopNotificationDismissed && !state.forceHideDesktopNotification,
|
||||
forceShowDisabled: (state) => state.forceDesktopNotification,
|
||||
forceHideDisabled: (state) => state.forceHideDesktopNotification,
|
||||
},
|
||||
{
|
||||
key: 'forcePremiumGracePeriod',
|
||||
label: <Trans>Premium Grace Period Nagbar</Trans>,
|
||||
forceKey: 'forcePremiumGracePeriod',
|
||||
forceHideKey: 'forceHidePremiumGracePeriod',
|
||||
resetKeys: ['forcePremiumGracePeriod'],
|
||||
status: (state) =>
|
||||
state.forcePremiumGracePeriod
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHidePremiumGracePeriod
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) => !state.forcePremiumGracePeriod && !state.forceHidePremiumGracePeriod,
|
||||
forceShowDisabled: (state) => state.forcePremiumGracePeriod,
|
||||
forceHideDisabled: (state) => state.forceHidePremiumGracePeriod,
|
||||
},
|
||||
{
|
||||
key: 'forcePremiumExpired',
|
||||
label: <Trans>Premium Expired Nagbar</Trans>,
|
||||
forceKey: 'forcePremiumExpired',
|
||||
forceHideKey: 'forceHidePremiumExpired',
|
||||
resetKeys: ['forcePremiumExpired'],
|
||||
status: (state) =>
|
||||
state.forcePremiumExpired
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHidePremiumExpired
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) => !state.forcePremiumExpired && !state.forceHidePremiumExpired,
|
||||
forceShowDisabled: (state) => state.forcePremiumExpired,
|
||||
forceHideDisabled: (state) => state.forceHidePremiumExpired,
|
||||
},
|
||||
{
|
||||
key: 'forcePremiumOnboarding',
|
||||
label: <Trans>Premium Onboarding Nagbar</Trans>,
|
||||
forceKey: 'forcePremiumOnboarding',
|
||||
forceHideKey: 'forceHidePremiumOnboarding',
|
||||
resetKeys: ['forcePremiumOnboarding', 'premiumOnboardingDismissed'],
|
||||
status: (state) =>
|
||||
state.forcePremiumOnboarding
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHidePremiumOnboarding
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: state.premiumOnboardingDismissed
|
||||
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) =>
|
||||
!state.forcePremiumOnboarding && !state.premiumOnboardingDismissed && !state.forceHidePremiumOnboarding,
|
||||
forceShowDisabled: (state) => state.forcePremiumOnboarding,
|
||||
forceHideDisabled: (state) => state.forceHidePremiumOnboarding,
|
||||
},
|
||||
{
|
||||
key: 'forceGiftInventory',
|
||||
label: <Trans>Gift Inventory Nagbar</Trans>,
|
||||
forceKey: 'forceGiftInventory',
|
||||
forceHideKey: 'forceHideGiftInventory',
|
||||
resetKeys: ['forceGiftInventory', 'giftInventoryDismissed'],
|
||||
status: (state) =>
|
||||
state.forceGiftInventory
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHideGiftInventory
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: state.giftInventoryDismissed
|
||||
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_GIFT_INVENTORY_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) =>
|
||||
!state.forceGiftInventory && !state.giftInventoryDismissed && !state.forceHideGiftInventory,
|
||||
forceShowDisabled: (state) => state.forceGiftInventory,
|
||||
forceHideDisabled: (state) => state.forceHideGiftInventory,
|
||||
},
|
||||
{
|
||||
key: 'forceInvitesDisabled',
|
||||
label: <Trans>Invites Disabled Nagbar</Trans>,
|
||||
forceKey: 'forceInvitesDisabled',
|
||||
forceHideKey: 'forceHideInvitesDisabled',
|
||||
resetKeys: ['forceInvitesDisabled'],
|
||||
status: (state) =>
|
||||
state.forceInvitesDisabled
|
||||
? t(FORCE_ENABLED_DESCRIPTOR)
|
||||
: state.forceHideInvitesDisabled
|
||||
? t(FORCE_DISABLED_DESCRIPTOR)
|
||||
: t(USING_ACTUAL_STATE_DESCRIPTOR),
|
||||
useActualDisabled: (state) => !state.forceInvitesDisabled && !state.forceHideInvitesDisabled,
|
||||
forceShowDisabled: (state) => state.forceInvitesDisabled,
|
||||
forceHideDisabled: (state) => state.forceHideInvitesDisabled,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import type {SelectOption} from '~/components/form/Select';
|
||||
|
||||
interface PremiumScenarioSelectOption extends Omit<SelectOption<PremiumScenarioOption>, 'label'> {
|
||||
label: MessageDescriptor;
|
||||
}
|
||||
|
||||
export type PremiumScenarioOption =
|
||||
| 'none'
|
||||
| 'free_no_purchases'
|
||||
| 'free_with_history'
|
||||
| 'active_monthly'
|
||||
| 'active_monthly_cancelled'
|
||||
| 'active_yearly'
|
||||
| 'active_yearly_cancelled'
|
||||
| 'gift_active_no_history'
|
||||
| 'gift_active_with_history'
|
||||
| 'gift_expiring_soon'
|
||||
| 'gift_grace_period_active'
|
||||
| 'gift_expired_recent'
|
||||
| 'grace_period_active'
|
||||
| 'expired_recent'
|
||||
| 'expired_old'
|
||||
| 'visionary'
|
||||
| 'reset';
|
||||
|
||||
export const PREMIUM_SCENARIO_OPTIONS: ReadonlyArray<PremiumScenarioSelectOption> = [
|
||||
{value: 'none', label: msg`Select a scenario to apply...`},
|
||||
{value: 'free_no_purchases', label: msg`Free User (No Purchases)`},
|
||||
{value: 'free_with_history', label: msg`Free User (With Purchase History)`},
|
||||
{value: 'active_monthly', label: msg`Active Monthly Subscriber`},
|
||||
{value: 'active_monthly_cancelled', label: msg`Active Monthly Subscriber (Cancellation Scheduled)`},
|
||||
{value: 'active_yearly', label: msg`Active Yearly Subscriber`},
|
||||
{value: 'active_yearly_cancelled', label: msg`Active Yearly Subscriber (Cancellation Scheduled)`},
|
||||
{value: 'gift_active_no_history', label: msg`Gift – Active (No Purchase History)`},
|
||||
{value: 'gift_active_with_history', label: msg`Gift – Active (Has Purchase History)`},
|
||||
{value: 'gift_expiring_soon', label: msg`Gift – Expiring Soon`},
|
||||
{value: 'gift_grace_period_active', label: msg`Gift – Grace Period (Still Have Access)`},
|
||||
{value: 'gift_expired_recent', label: msg`Gift – Expired (Within 30 Days)`},
|
||||
{value: 'grace_period_active', label: msg`Grace Period (Still Have Access)`},
|
||||
{value: 'expired_recent', label: msg`Expired (Within 30 Days)`},
|
||||
{value: 'expired_old', label: msg`Expired (Over 30 Days Ago)`},
|
||||
{value: 'visionary', label: msg`Visionary (Lifetime #42)`},
|
||||
{value: 'reset', label: msg`Reset to Actual Values`},
|
||||
];
|
||||
|
||||
export const applyPremiumScenarioOption = (scenario: PremiumScenarioOption) => {
|
||||
if (scenario === 'none') return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
switch (scenario) {
|
||||
case 'free_no_purchases':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'free_with_history':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'active_monthly':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 15 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 15 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'monthly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'active_monthly_cancelled':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 20 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 10 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'monthly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', true);
|
||||
break;
|
||||
case 'active_yearly':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 180 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 185 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'active_yearly_cancelled':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 250 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 60 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', true);
|
||||
break;
|
||||
case 'gift_active_no_history':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 10 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 20 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'gift_active_with_history':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 5 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 60 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'gift_expiring_soon':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 25 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'gift_grace_period_active':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 31 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'gift_expired_recent':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 35 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 5 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'grace_period_active':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 31 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'expired_recent':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 35 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 5 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'expired_old':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 65 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 35 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'visionary':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.LIFETIME);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 365 * 86400000));
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
|
||||
break;
|
||||
case 'reset':
|
||||
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
|
||||
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', null);
|
||||
break;
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user