feat(discovery): more work on discovery plus a few fixes

This commit is contained in:
Hampus Kraft
2026-02-17 15:41:08 +00:00
parent b19e9fb243
commit 302c0d2a0c
137 changed files with 7116 additions and 2047 deletions

View File

@@ -121,6 +121,7 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-4);
padding-top: var(--spacing-5);
padding-bottom: var(--spacing-5);
}

View File

@@ -74,7 +74,7 @@
.userName {
display: block;
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;

View File

@@ -94,28 +94,34 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-top: 1px solid var(--background-modifier-accent);
}
.stats {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.stat {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.25rem;
}
.stat {
display: inline-flex;
align-items: center;
min-width: 0;
}
.joinButton {
width: 100%;
}
.statDot {
margin-right: 0.3rem;
height: 0.5rem;
width: 0.5rem;
border-radius: var(--radius-full);
flex: 0 0 auto;
}
.statDotOnline {
@@ -125,10 +131,12 @@
.statDotMembers {
composes: statDot;
background-color: var(--text-quaternary);
background-color: var(--text-tertiary-secondary);
}
.statText {
color: var(--text-tertiary);
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.2;
white-space: nowrap;
}

View File

@@ -19,11 +19,13 @@
import type {DiscoveryGuild} from '@app/actions/DiscoveryActionCreators';
import * as DiscoveryActionCreators from '@app/actions/DiscoveryActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {GuildBadge} from '@app/components/guild/GuildBadge';
import styles from '@app/components/modals/discovery/DiscoveryGuildCard.module.css';
import {GuildIcon} from '@app/components/popouts/GuildIcon';
import {Button} from '@app/components/uikit/button/Button';
import GuildStore from '@app/stores/GuildStore';
import {getApiErrorMessage} from '@app/utils/ApiErrorUtils';
import {getCurrentLocale} from '@app/utils/LocaleUtils';
import type {DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
import {DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
@@ -40,7 +42,7 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
const {t} = useLingui();
const [joining, setJoining] = useState(false);
const isAlreadyMember = GuildStore.getGuild(guild.id) != null;
const categoryLabel = DiscoveryCategoryLabels[guild.category_id as DiscoveryCategory] ?? '';
const categoryLabel = DiscoveryCategoryLabels[guild.category_type as DiscoveryCategory] ?? '';
const memberCount = formatNumber(guild.member_count, getCurrentLocale());
const onlineCount = formatNumber(guild.online_count, getCurrentLocale());
@@ -49,10 +51,12 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
setJoining(true);
try {
await DiscoveryActionCreators.joinGuild(guild.id);
} catch {
} catch (error) {
setJoining(false);
const message = getApiErrorMessage(error) ?? t`Failed to join this community. Please try again.`;
ToastActionCreators.error(message);
}
}, [guild.id, joining, isAlreadyMember]);
}, [guild.id, joining, isAlreadyMember, t]);
return (
<div className={styles.card}>
@@ -69,12 +73,10 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
</div>
<div className={styles.footer}>
<div className={styles.stats}>
{guild.online_count > 0 && (
<div className={styles.stat}>
<div className={styles.statDotOnline} />
<span className={styles.statText}>{t`${onlineCount} Online`}</span>
</div>
)}
<div className={styles.stat}>
<div className={styles.statDotOnline} />
<span className={styles.statText}>{t`${onlineCount} Online`}</span>
</div>
<div className={styles.stat}>
<div className={styles.statDotMembers} />
<span className={styles.statText}>
@@ -82,8 +84,13 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
</span>
</div>
</div>
<Button variant="primary" small onClick={handleJoin} disabled={joining || isAlreadyMember}>
{isAlreadyMember ? t`Joined` : t`Join`}
<Button
variant="primary"
className={styles.joinButton}
onClick={handleJoin}
disabled={joining || isAlreadyMember}
>
{isAlreadyMember ? t`Joined` : t`Join Community`}
</Button>
</div>
</div>

View File

@@ -0,0 +1,210 @@
/*
* 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: 1rem;
}
.header {
display: flex;
flex-direction: column;
}
.title {
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 1.25rem;
line-height: 1.75rem;
}
.subtitle {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.spinnerContainer {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.statusCard {
display: flex;
flex-direction: column;
gap: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 1rem;
}
.statusRow {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.statusLabel {
color: var(--text-primary-muted);
}
.statusBadge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 9999px;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
}
.statusPending {
background-color: rgba(234, 179, 8, 0.15);
color: rgb(234, 179, 8);
}
.statusApproved {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(34, 197, 94);
}
.statusRejected {
background-color: rgba(239, 68, 68, 0.15);
color: rgb(239, 68, 68);
}
.statusRemoved {
background-color: rgba(239, 68, 68, 0.15);
color: rgb(239, 68, 68);
}
.reviewReason {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.formCard {
display: flex;
flex-direction: column;
gap: 1rem;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 1rem;
}
.fieldLabel {
margin-bottom: 0.5rem;
display: block;
font-weight: 600;
font-size: 0.875rem;
line-height: 1.25rem;
}
.helpText {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-primary-muted);
}
.charCount {
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-primary-muted);
text-align: right;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.actions > * {
flex: none;
}
.warning {
border-radius: 0.375rem;
border: 1px solid rgba(234, 179, 8, 0.5);
background-color: rgba(234, 179, 8, 0.1);
padding: 1rem;
}
.warningContent {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.warningIcon {
margin-top: 0.125rem;
color: rgb(234, 179, 8);
}
.warningBody {
flex: 1;
}
.warningTitle {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.25rem;
color: rgb(234, 179, 8);
}
.warningText {
margin-top: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.info {
border-radius: 0.375rem;
border: 1px solid rgba(59, 130, 246, 0.5);
background-color: rgba(59, 130, 246, 0.1);
padding: 1rem;
}
.infoContent {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.infoIcon {
margin-top: 0.125rem;
color: rgb(59, 130, 246);
}
.infoText {
flex: 1;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,356 @@
/*
* 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 * as GuildActionCreators from '@app/actions/GuildActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Textarea} from '@app/components/form/Input';
import {Select, type SelectOption} from '@app/components/form/Select';
import styles from '@app/components/modals/guild_tabs/GuildDiscoveryTab.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Spinner} from '@app/components/uikit/Spinner';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Logger} from '@app/lib/Logger';
import {
DISCOVERY_DESCRIPTION_MAX_LENGTH,
DISCOVERY_DESCRIPTION_MIN_LENGTH,
DiscoveryApplicationStatus,
} from '@fluxer/constants/src/DiscoveryConstants';
import type {
DiscoveryApplicationResponse,
DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {InfoIcon, WarningIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {Controller, useForm} from 'react-hook-form';
const logger = new Logger('GuildDiscoveryTab');
interface FormInputs {
description: string;
category_type: number;
}
function StatusBadge({status}: {status: string}) {
const {t} = useLingui();
const statusConfig: Record<string, {label: string; className: string}> = useMemo(
() => ({
[DiscoveryApplicationStatus.PENDING]: {label: t`Pending`, className: styles.statusPending},
[DiscoveryApplicationStatus.APPROVED]: {label: t`Approved`, className: styles.statusApproved},
[DiscoveryApplicationStatus.REJECTED]: {label: t`Rejected`, className: styles.statusRejected},
[DiscoveryApplicationStatus.REMOVED]: {label: t`Removed`, className: styles.statusRemoved},
}),
[t],
);
const config = statusConfig[status];
if (!config) return null;
return <span className={clsx(styles.statusBadge, config.className)}>{config.label}</span>;
}
const GuildDiscoveryTab: React.FC<{guildId: string}> = ({guildId}) => {
const {t} = useLingui();
const [status, setStatus] = useState<DiscoveryStatusResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isWithdrawing, setIsWithdrawing] = useState(false);
const categoryOptions: ReadonlyArray<SelectOption<number>> = useMemo(
() => [
{value: 0, label: t`Gaming`},
{value: 1, label: t`Music`},
{value: 2, label: t`Entertainment`},
{value: 3, label: t`Education`},
{value: 4, label: t`Science & Technology`},
{value: 5, label: t`Content Creator`},
{value: 6, label: t`Anime & Manga`},
{value: 7, label: t`Movies & TV`},
{value: 8, label: t`Other`},
],
[t],
);
const fetchStatus = useCallback(async () => {
try {
setIsLoading(true);
const data = await GuildActionCreators.getDiscoveryStatus(guildId);
setStatus(data);
} catch (err) {
logger.error('Failed to fetch discovery status', err);
} finally {
setIsLoading(false);
}
}, [guildId]);
useEffect(() => {
void fetchStatus();
}, [fetchStatus]);
const application = status?.application ?? null;
const eligible = status?.eligible ?? false;
const minMemberCount = status?.min_member_count ?? 0;
const hasActiveApplication =
application != null &&
(application.status === DiscoveryApplicationStatus.PENDING ||
application.status === DiscoveryApplicationStatus.APPROVED);
const canApply =
!hasActiveApplication &&
(application == null ||
application.status === DiscoveryApplicationStatus.REJECTED ||
application.status === DiscoveryApplicationStatus.REMOVED);
const form = useForm<FormInputs>({
defaultValues: {
description: hasActiveApplication ? application.description : '',
category_type: hasActiveApplication ? application.category_type : 0,
},
});
useEffect(() => {
if (hasActiveApplication && application) {
form.reset({
description: application.description,
category_type: application.category_type,
});
}
}, [application, hasActiveApplication, form]);
const setApplicationFromResponse = useCallback((response: DiscoveryApplicationResponse) => {
setStatus((prev) => (prev ? {...prev, application: response} : prev));
}, []);
const onSubmit = useCallback(
async (data: FormInputs) => {
if (hasActiveApplication) {
const result = await GuildActionCreators.updateDiscoveryApplication(guildId, {
description: data.description,
category_type: data.category_type,
});
setApplicationFromResponse(result);
form.reset(data);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Discovery listing updated</Trans>,
});
} else {
const result = await GuildActionCreators.applyForDiscovery(guildId, {
description: data.description,
category_type: data.category_type,
});
setApplicationFromResponse(result);
form.reset(data);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Discovery application submitted</Trans>,
});
}
},
[guildId, hasActiveApplication, form, setApplicationFromResponse],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'description',
});
const handleWithdraw = useCallback(async () => {
try {
setIsWithdrawing(true);
await GuildActionCreators.withdrawDiscoveryApplication(guildId);
setStatus((prev) => (prev ? {...prev, application: null} : prev));
form.reset({description: '', category_type: 0});
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Discovery application withdrawn</Trans>,
});
} catch (err) {
logger.error('Failed to withdraw discovery application', err);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to withdraw application. Please try again.</Trans>,
});
} finally {
setIsWithdrawing(false);
}
}, [guildId, form]);
if (isLoading) {
return (
<div className={styles.spinnerContainer}>
<Spinner />
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>
<Trans>Discovery</Trans>
</h2>
<p className={styles.subtitle}>
<Trans>List your community in Discovery so others can find and join it.</Trans>
</p>
</div>
{!eligible && canApply && (
<div className={styles.warning}>
<div className={styles.warningContent}>
<div className={styles.warningIcon}>
<WarningIcon size={20} weight="fill" />
</div>
<div className={styles.warningBody}>
<p className={styles.warningTitle}>
<Trans>Not enough members</Trans>
</p>
<p className={styles.warningText}>
<Trans>
Your community needs at least {minMemberCount} members before it can be listed in Discovery.
</Trans>
</p>
</div>
</div>
</div>
)}
{application != null && (
<div className={styles.statusCard}>
<div className={styles.statusRow}>
<span className={styles.statusLabel}>
<Trans>Status:</Trans>
</span>
<StatusBadge status={application.status} />
</div>
{application.review_reason && (
<p className={styles.reviewReason}>
<Trans>Reason: {application.review_reason}</Trans>
</p>
)}
</div>
)}
{application?.status === DiscoveryApplicationStatus.APPROVED && (
<div className={styles.info}>
<div className={styles.infoContent}>
<div className={styles.infoIcon}>
<InfoIcon size={20} weight="fill" />
</div>
<p className={styles.infoText}>
<Trans>
Your community is listed in Discovery. You can update your listing details below or withdraw to remove
it.
</Trans>
</p>
</div>
</div>
)}
{application?.status === DiscoveryApplicationStatus.PENDING && (
<div className={styles.info}>
<div className={styles.infoContent}>
<div className={styles.infoIcon}>
<InfoIcon size={20} weight="fill" />
</div>
<p className={styles.infoText}>
<Trans>
Your application is pending review. You can still update your listing details or withdraw the
application.
</Trans>
</p>
</div>
</div>
)}
{(canApply || hasActiveApplication) && (
<Form form={form} onSubmit={handleSubmit}>
<div className={styles.formCard}>
<div>
<div className={styles.fieldLabel}>
<Trans>Description</Trans>
</div>
<Textarea
{...form.register('description', {
required: t`A description is required.`,
minLength: {
value: DISCOVERY_DESCRIPTION_MIN_LENGTH,
message: t`Description must be at least ${DISCOVERY_DESCRIPTION_MIN_LENGTH} characters.`,
},
maxLength: {
value: DISCOVERY_DESCRIPTION_MAX_LENGTH,
message: t`Description must be no more than ${DISCOVERY_DESCRIPTION_MAX_LENGTH} characters.`,
},
})}
error={form.formState.errors.description?.message}
label=""
placeholder={t`Describe what your community is about...`}
minRows={3}
maxRows={6}
maxLength={DISCOVERY_DESCRIPTION_MAX_LENGTH}
showCharacterCount
disabled={!eligible && canApply}
/>
</div>
<div>
<div className={styles.fieldLabel}>
<Trans>Category</Trans>
</div>
<Controller
name="category_type"
control={form.control}
render={({field}) => (
<Select<number>
value={field.value}
onChange={field.onChange}
options={categoryOptions}
isSearchable={false}
disabled={!eligible && canApply}
/>
)}
/>
<p className={styles.helpText}>
<Trans>Choose the category that best describes your community.</Trans>
</p>
</div>
<div className={styles.actions}>
{hasActiveApplication && (
<Button type="button" variant="danger-secondary" onClick={handleWithdraw} submitting={isWithdrawing}>
<Trans>Withdraw</Trans>
</Button>
)}
<Button type="submit" submitting={isSubmitting} disabled={!eligible && canApply}>
{hasActiveApplication ? <Trans>Save</Trans> : <Trans>Apply</Trans>}
</Button>
</div>
</div>
</Form>
)}
</div>
);
};
export default GuildDiscoveryTab;

View File

@@ -237,7 +237,3 @@
color: var(--text-link);
text-decoration: underline;
}
.permissionWarning {
margin-top: 0.25rem;
}

View File

@@ -20,7 +20,6 @@
import {Switch as UISwitch} from '@app/components/form/Switch';
import styles from '@app/components/modals/shared/PermissionComponents.module.css';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {WarningAlert} from '@app/components/uikit/warning_alert/WarningAlert';
import PermissionLayoutStore from '@app/stores/PermissionLayoutStore';
import type * as PermissionUtils from '@app/utils/PermissionUtils';
import {useLingui} from '@lingui/react/macro';
@@ -128,6 +127,7 @@ const PermissionOverwriteToggle: React.FC<{
const buttons = <PermissionStateButtons currentState={state} onStateChange={onChange} disabled={disabled} />;
const buttonsTooltipTrigger = <div className={styles.tooltipTriggerInline}>{buttons}</div>;
const showDescription = PermissionLayoutStore.isComfy;
const tooltipText = (disabled && disabledReason) || warning;
return (
<div className={clsx(styles.overwriteToggle, PermissionLayoutStore.isDense && styles.overwriteToggleDense)}>
@@ -142,10 +142,9 @@ const PermissionOverwriteToggle: React.FC<{
</div>
{showDescription && description && <p className={styles.overwriteToggleDescription}>{description}</p>}
{extra}
{warning && <WarningAlert className={styles.permissionWarning}>{warning}</WarningAlert>}
</div>
<div className={styles.overwriteToggleActions}>
{disabled && disabledReason ? <Tooltip text={disabledReason}>{buttonsTooltipTrigger}</Tooltip> : buttons}
{tooltipText ? <Tooltip text={tooltipText}>{buttonsTooltipTrigger}</Tooltip> : buttons}
</div>
</div>
);
@@ -238,12 +237,12 @@ const PermissionRoleToggle: React.FC<{
/>
);
const switchTooltipTrigger = <div className={styles.tooltipTriggerBlock}>{switchEl}</div>;
const tooltipText = (disabled && disabledReason) || warning;
return (
<div className={clsx(styles.roleToggle, PermissionLayoutStore.isDense && styles.roleToggleDense)}>
{disabled && disabledReason ? <Tooltip text={disabledReason}>{switchTooltipTrigger}</Tooltip> : switchEl}
{tooltipText ? <Tooltip text={tooltipText}>{switchTooltipTrigger}</Tooltip> : switchEl}
{extra}
{warning && <WarningAlert className={styles.permissionWarning}>{warning}</WarningAlert>}
</div>
);
},

View File

@@ -19,6 +19,7 @@
import GuildAuditLogTab from '@app/components/modals/guild_tabs/GuildAuditLogTab';
import GuildBansTab from '@app/components/modals/guild_tabs/GuildBansTab';
import GuildDiscoveryTab from '@app/components/modals/guild_tabs/GuildDiscoveryTab';
import GuildEmojiTab from '@app/components/modals/guild_tabs/GuildEmojiTab';
import GuildInvitesTab from '@app/components/modals/guild_tabs/GuildInvitesTab';
import GuildModerationTab from '@app/components/modals/guild_tabs/GuildModerationTab';
@@ -33,6 +34,7 @@ import type {I18n, MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {
BookOpenIcon,
CompassIcon,
GearIcon,
HammerIcon,
type Icon,
@@ -57,6 +59,7 @@ export type GuildSettingsTabType =
| 'audit_log'
| 'webhooks'
| 'vanity_url'
| 'discovery'
| 'members'
| 'invites'
| 'bans';
@@ -151,6 +154,15 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array<GuildSettingsTabDescriptor> = [
permission: Permissions.MANAGE_GUILD,
requireFeature: GuildFeatures.VANITY_URL,
},
{
type: 'discovery',
category: 'guild_settings',
label: msg`Discovery`,
icon: CompassIcon,
iconWeight: 'fill',
component: GuildDiscoveryTab,
permission: Permissions.MANAGE_GUILD,
},
{
type: 'members',
category: 'user_management',