From 302c0d2a0ce71f522d066da57dfe9d7ac17ef272 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Tue, 17 Feb 2026 15:41:08 +0000 Subject: [PATCH] feat(discovery): more work on discovery plus a few fixes --- config/config.dev.template.json | 3 + config/config.test.json | 3 + .../src/actions/DiscoveryActionCreators.tsx | 2 +- .../src/actions/GuildActionCreators.tsx | 54 +++ .../components/guild/GuildBadge.module.css | 39 ++ .../src/components/guild/GuildBadge.tsx | 22 +- .../layout/guild_list/DiscoveryButton.tsx | 2 +- .../modals/DiscoveryModal.module.css | 1 + .../modals/UserProfileModal.module.css | 2 +- .../discovery/DiscoveryGuildCard.module.css | 30 +- .../modals/discovery/DiscoveryGuildCard.tsx | 29 +- .../guild_tabs/GuildDiscoveryTab.module.css | 210 ++++++++++ .../modals/guild_tabs/GuildDiscoveryTab.tsx | 356 ++++++++++++++++ .../shared/PermissionComponents.module.css | 4 - .../modals/shared/PermissionComponents.tsx | 9 +- .../modals/utils/GuildSettingsConstants.tsx | 12 + fluxer_app/src/lib/ReadStateCleanup.tsx | 14 +- fluxer_app/src/locales/ar/messages.po | 196 +++++++-- fluxer_app/src/locales/bg/messages.po | 196 +++++++-- fluxer_app/src/locales/cs/messages.po | 196 +++++++-- fluxer_app/src/locales/da/messages.po | 196 +++++++-- fluxer_app/src/locales/de/messages.po | 196 +++++++-- fluxer_app/src/locales/el/messages.po | 196 +++++++-- fluxer_app/src/locales/en-GB/messages.po | 196 +++++++-- fluxer_app/src/locales/en-US/messages.po | 196 +++++++-- fluxer_app/src/locales/es-419/messages.po | 196 +++++++-- fluxer_app/src/locales/es-ES/messages.po | 196 +++++++-- fluxer_app/src/locales/fi/messages.po | 196 +++++++-- fluxer_app/src/locales/fr/messages.po | 196 +++++++-- fluxer_app/src/locales/he/messages.po | 196 +++++++-- fluxer_app/src/locales/hi/messages.po | 196 +++++++-- fluxer_app/src/locales/hr/messages.po | 196 +++++++-- fluxer_app/src/locales/hu/messages.po | 196 +++++++-- fluxer_app/src/locales/id/messages.po | 196 +++++++-- fluxer_app/src/locales/it/messages.po | 196 +++++++-- fluxer_app/src/locales/ja/messages.po | 196 +++++++-- fluxer_app/src/locales/ko/messages.po | 196 +++++++-- fluxer_app/src/locales/lt/messages.po | 196 +++++++-- fluxer_app/src/locales/nl/messages.po | 196 +++++++-- fluxer_app/src/locales/no/messages.po | 196 +++++++-- fluxer_app/src/locales/pl/messages.po | 196 +++++++-- fluxer_app/src/locales/pt-BR/messages.po | 196 +++++++-- fluxer_app/src/locales/ro/messages.po | 196 +++++++-- fluxer_app/src/locales/ru/messages.po | 196 +++++++-- fluxer_app/src/locales/sv-SE/messages.po | 196 +++++++-- fluxer_app/src/locales/th/messages.po | 196 +++++++-- fluxer_app/src/locales/tr/messages.po | 196 +++++++-- fluxer_app/src/locales/uk/messages.po | 196 +++++++-- fluxer_app/src/locales/vi/messages.po | 196 +++++++-- fluxer_app/src/locales/zh-CN/messages.po | 196 +++++++-- fluxer_app/src/locales/zh-TW/messages.po | 196 +++++++-- fluxer_app/src/stores/GuildReadStateStore.tsx | 14 +- fluxer_app/src/stores/ReadStateStore.tsx | 8 - .../src/utils/MessageRequestUtils.test.tsx | 22 + fluxer_app/src/utils/MessageRequestUtils.tsx | 4 +- .../20260217140000_guild_discovery.cql | 2 +- packages/admin/src/pages/DiscoveryPage.tsx | 2 +- packages/api/src/Config.tsx | 4 + packages/api/src/admin/AdminService.tsx | 2 + .../controllers/DiscoveryAdminController.tsx | 2 +- .../src/admin/services/AdminGuildService.tsx | 3 + .../guild/AdminGuildUpdatePropagator.tsx | 25 +- .../tests/DiscoveryAdminOperations.test.tsx | 7 +- .../channel_data/ChannelOperationsService.tsx | 25 ++ .../services/message/MessageSendService.tsx | 6 +- packages/api/src/config/APIConfig.tsx | 5 + .../database/types/GuildDiscoveryTypes.tsx | 4 +- .../controllers/GuildDiscoveryController.tsx | 36 +- .../repositories/GuildDiscoveryRepository.tsx | 11 +- .../src/guild/services/GuildDataService.tsx | 2 + .../guild/services/GuildDiscoveryService.tsx | 38 +- .../src/guild/services/GuildRoleService.tsx | 137 +----- .../services/data/GuildOperationsService.tsx | 15 +- .../DiscoveryApplicationLifecycle.test.tsx | 46 +- .../DiscoveryApplicationValidation.test.tsx | 57 +-- .../tests/DiscoverySearchAndJoin.test.tsx | 61 ++- .../src/guild/tests/GuildRoleReorder.test.tsx | 396 ++++++++++++++++++ packages/api/src/rpc/RpcService.tsx | 33 +- packages/api/src/test/NoopGatewayService.tsx | 275 ++++++------ .../tests/PushSubscriptionLifecycle.test.tsx | 11 +- packages/api/src/user/tests/UserTestUtils.tsx | 8 +- .../tests/GitHubIssueTransformer.test.tsx | 6 +- .../transformers/GitHubIssueTransformer.tsx | 6 +- .../src/worker/tasks/SyncDiscoveryIndex.tsx | 2 +- packages/config/src/ConfigSchema.json | 21 + .../config/src/schema/defs/discovery.json | 19 + packages/config/src/schema/root.json | 5 + packages/constants/src/ApiErrorCodes.tsx | 1 + .../src/ApiErrorCodesDescriptions.tsx | 1 + packages/constants/src/DiscoveryConstants.tsx | 2 - .../discovery/DiscoveryDisabledError.tsx} | 27 +- .../errors/src/i18n/ErrorCodeMappings.tsx | 1 + .../src/i18n/ErrorI18nTypes.generated.tsx | 1 + packages/errors/src/i18n/locales/ar.yaml | 1 + packages/errors/src/i18n/locales/bg.yaml | 1 + packages/errors/src/i18n/locales/cs.yaml | 1 + packages/errors/src/i18n/locales/da.yaml | 1 + packages/errors/src/i18n/locales/de.yaml | 1 + packages/errors/src/i18n/locales/el.yaml | 1 + packages/errors/src/i18n/locales/en-GB.yaml | 1 + packages/errors/src/i18n/locales/es-419.yaml | 1 + packages/errors/src/i18n/locales/es-ES.yaml | 1 + packages/errors/src/i18n/locales/fi.yaml | 1 + packages/errors/src/i18n/locales/fr.yaml | 1 + packages/errors/src/i18n/locales/he.yaml | 1 + packages/errors/src/i18n/locales/hi.yaml | 1 + packages/errors/src/i18n/locales/hr.yaml | 1 + packages/errors/src/i18n/locales/hu.yaml | 1 + packages/errors/src/i18n/locales/id.yaml | 1 + packages/errors/src/i18n/locales/it.yaml | 1 + packages/errors/src/i18n/locales/ja.yaml | 1 + packages/errors/src/i18n/locales/ko.yaml | 1 + packages/errors/src/i18n/locales/lt.yaml | 1 + .../errors/src/i18n/locales/messages.yaml | 1 + packages/errors/src/i18n/locales/nl.yaml | 1 + packages/errors/src/i18n/locales/no.yaml | 1 + packages/errors/src/i18n/locales/pl.yaml | 1 + packages/errors/src/i18n/locales/pt-BR.yaml | 1 + packages/errors/src/i18n/locales/ro.yaml | 1 + packages/errors/src/i18n/locales/ru.yaml | 1 + packages/errors/src/i18n/locales/sv-SE.yaml | 1 + packages/errors/src/i18n/locales/th.yaml | 1 + packages/errors/src/i18n/locales/tr.yaml | 1 + packages/errors/src/i18n/locales/uk.yaml | 1 + packages/errors/src/i18n/locales/vi.yaml | 1 + packages/errors/src/i18n/locales/zh-CN.yaml | 1 + packages/errors/src/i18n/locales/zh-TW.yaml | 1 + packages/marketing/src/App.tsx | 8 - packages/marketing/src/BadgeProxy.tsx | 110 ----- packages/marketing/src/MarketingContext.tsx | 3 - .../src/app/MarketingContextFactory.tsx | 5 - .../src/app/MarketingRouteRegistrar.tsx | 14 - packages/marketing/src/components/Hero.tsx | 63 ++- .../src/components/LaunchBlogSection.tsx | 67 --- packages/marketing/src/pages/HomePage.tsx | 2 - .../domains/guild/GuildDiscoverySchemas.tsx | 16 +- scripts/dev_bootstrap.sh | 32 +- 137 files changed, 7116 insertions(+), 2047 deletions(-) create mode 100644 fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.module.css create mode 100644 fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.tsx create mode 100644 packages/api/src/guild/tests/GuildRoleReorder.test.tsx create mode 100644 packages/config/src/schema/defs/discovery.json rename packages/{marketing/src/components/MadeInSwedenBadge.tsx => errors/src/domains/discovery/DiscoveryDisabledError.tsx} (50%) delete mode 100644 packages/marketing/src/BadgeProxy.tsx delete mode 100644 packages/marketing/src/components/LaunchBlogSection.tsx diff --git a/config/config.dev.template.json b/config/config.dev.template.json index df2ffed9..f2c567cb 100644 --- a/config/config.dev.template.json +++ b/config/config.dev.template.json @@ -63,6 +63,9 @@ "keys": [] } }, + "discovery": { + "min_member_count": 1 + }, "dev": { "disable_rate_limits": true }, diff --git a/config/config.test.json b/config/config.test.json index f129e411..8dbc577c 100644 --- a/config/config.test.json +++ b/config/config.test.json @@ -47,6 +47,9 @@ "keys": [] } }, + "discovery": { + "min_member_count": 1 + }, "dev": { "disable_rate_limits": true, "test_mode_enabled": true, diff --git a/fluxer_app/src/actions/DiscoveryActionCreators.tsx b/fluxer_app/src/actions/DiscoveryActionCreators.tsx index e985f374..abdd0a9a 100644 --- a/fluxer_app/src/actions/DiscoveryActionCreators.tsx +++ b/fluxer_app/src/actions/DiscoveryActionCreators.tsx @@ -28,7 +28,7 @@ export interface DiscoveryGuild { name: string; icon: string | null; description: string | null; - category_id: number; + category_type: number; member_count: number; online_count: number; features: Array; diff --git a/fluxer_app/src/actions/GuildActionCreators.tsx b/fluxer_app/src/actions/GuildActionCreators.tsx index 36032476..e9311f6d 100644 --- a/fluxer_app/src/actions/GuildActionCreators.tsx +++ b/fluxer_app/src/actions/GuildActionCreators.tsx @@ -27,6 +27,11 @@ import type { AuditLogWebhookResponse, GuildAuditLogEntryResponse, } from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import type { + DiscoveryApplicationRequest, + DiscoveryApplicationResponse, + DiscoveryStatusResponse, +} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {GuildRole} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas'; @@ -377,3 +382,52 @@ export async function fetchGuildAuditLogs( throw error; } } + +export async function getDiscoveryStatus(guildId: string): Promise { + try { + const response = await http.get(Endpoints.GUILD_DISCOVERY(guildId)); + logger.debug(`Fetched discovery status for guild ${guildId}`); + return response.body; + } catch (error) { + logger.error(`Failed to fetch discovery status for guild ${guildId}:`, error); + throw error; + } +} + +export async function applyForDiscovery( + guildId: string, + params: DiscoveryApplicationRequest, +): Promise { + try { + const response = await http.post(Endpoints.GUILD_DISCOVERY(guildId), params); + logger.debug(`Applied for discovery for guild ${guildId}`); + return response.body; + } catch (error) { + logger.error(`Failed to apply for discovery for guild ${guildId}:`, error); + throw error; + } +} + +export async function updateDiscoveryApplication( + guildId: string, + params: Partial, +): Promise { + try { + const response = await http.patch(Endpoints.GUILD_DISCOVERY(guildId), params); + logger.debug(`Updated discovery application for guild ${guildId}`); + return response.body; + } catch (error) { + logger.error(`Failed to update discovery application for guild ${guildId}:`, error); + throw error; + } +} + +export async function withdrawDiscoveryApplication(guildId: string): Promise { + try { + await http.delete({url: Endpoints.GUILD_DISCOVERY(guildId)}); + logger.debug(`Withdrew discovery application for guild ${guildId}`); + } catch (error) { + logger.error(`Failed to withdraw discovery application for guild ${guildId}:`, error); + throw error; + } +} diff --git a/fluxer_app/src/components/guild/GuildBadge.module.css b/fluxer_app/src/components/guild/GuildBadge.module.css index 7feeadb6..d84e60c0 100644 --- a/fluxer_app/src/components/guild/GuildBadge.module.css +++ b/fluxer_app/src/components/guild/GuildBadge.module.css @@ -19,3 +19,42 @@ color: white; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9)); } + +.partnerBadge { + display: flex; + align-items: center; + justify-content: center; + height: 1rem; + width: 1rem; + flex-shrink: 0; + border-radius: 50%; + background-color: var(--brand-primary); +} + +.partnerBadgeLarge { + display: flex; + align-items: center; + justify-content: center; + height: 1.15rem; + width: 1.15rem; + flex-shrink: 0; + border-radius: 50%; + background-color: var(--brand-primary); +} + +.partnerBadgeBanner { + display: flex; + align-items: center; + justify-content: center; + height: 1rem; + width: 1rem; + flex-shrink: 0; + border-radius: 50%; + background-color: var(--brand-primary); +} + +.partnerIcon { + color: white; + width: 62.5%; + height: 62.5%; +} diff --git a/fluxer_app/src/components/guild/GuildBadge.tsx b/fluxer_app/src/components/guild/GuildBadge.tsx index 499c7897..c6a2ce21 100644 --- a/fluxer_app/src/components/guild/GuildBadge.tsx +++ b/fluxer_app/src/components/guild/GuildBadge.tsx @@ -52,16 +52,30 @@ export function GuildBadge({ return null; } - const className = variant === 'banner' ? styles.badgeBanner : variant === 'large' ? styles.badgeLarge : styles.badge; - - const IconComponent = isPartnered ? InfinityIcon : SealCheckIcon; const tooltipText = isPartnered ? isVerified ? t`Verified & Partnered Community` : t`Partnered Community` : t`Verified Community`; - const icon = ; + let icon: React.JSX.Element; + if (isPartnered) { + const partnerClassName = + variant === 'banner' + ? styles.partnerBadgeBanner + : variant === 'large' + ? styles.partnerBadgeLarge + : styles.partnerBadge; + icon = ( + + + + ); + } else { + const verifiedClassName = + variant === 'banner' ? styles.badgeBanner : variant === 'large' ? styles.badgeLarge : styles.badge; + icon = ; + } if (!showTooltip) { return icon; diff --git a/fluxer_app/src/components/layout/guild_list/DiscoveryButton.tsx b/fluxer_app/src/components/layout/guild_list/DiscoveryButton.tsx index 0fd1dab9..cbea2260 100644 --- a/fluxer_app/src/components/layout/guild_list/DiscoveryButton.tsx +++ b/fluxer_app/src/components/layout/guild_list/DiscoveryButton.tsx @@ -64,7 +64,7 @@ export const DiscoveryButton = observer(() => { transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}} whileHover={AccessibilityStore.useReducedMotion ? undefined : {borderRadius: '30%'}} > - + diff --git a/fluxer_app/src/components/modals/DiscoveryModal.module.css b/fluxer_app/src/components/modals/DiscoveryModal.module.css index fb913f01..fcf64d33 100644 --- a/fluxer_app/src/components/modals/DiscoveryModal.module.css +++ b/fluxer_app/src/components/modals/DiscoveryModal.module.css @@ -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); } diff --git a/fluxer_app/src/components/modals/UserProfileModal.module.css b/fluxer_app/src/components/modals/UserProfileModal.module.css index 71434668..f27658b2 100644 --- a/fluxer_app/src/components/modals/UserProfileModal.module.css +++ b/fluxer_app/src/components/modals/UserProfileModal.module.css @@ -74,7 +74,7 @@ .userName { display: block; - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; max-width: 100%; overflow: hidden; diff --git a/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.module.css b/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.module.css index fd4f1811..23b4d9bf 100644 --- a/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.module.css +++ b/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.module.css @@ -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; } diff --git a/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.tsx b/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.tsx index af46a92e..c927d313 100644 --- a/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.tsx +++ b/fluxer_app/src/components/modals/discovery/DiscoveryGuildCard.tsx @@ -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 (
@@ -69,12 +73,10 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
- {guild.online_count > 0 && ( -
-
- {t`${onlineCount} Online`} -
- )} +
+
+ {t`${onlineCount} Online`} +
@@ -82,8 +84,13 @@ export const DiscoveryGuildCard = observer(function DiscoveryGuildCard({guild}:
-
diff --git a/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.module.css b/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.module.css new file mode 100644 index 00000000..ce533fd5 --- /dev/null +++ b/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.module.css @@ -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 . + */ + +.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); +} diff --git a/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.tsx b/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.tsx new file mode 100644 index 00000000..270313e2 --- /dev/null +++ b/fluxer_app/src/components/modals/guild_tabs/GuildDiscoveryTab.tsx @@ -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 . + */ + +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 = 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 {config.label}; +} + +const GuildDiscoveryTab: React.FC<{guildId: string}> = ({guildId}) => { + const {t} = useLingui(); + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isWithdrawing, setIsWithdrawing] = useState(false); + + const categoryOptions: ReadonlyArray> = 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({ + 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: Discovery listing updated, + }); + } 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: Discovery application submitted, + }); + } + }, + [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: Discovery application withdrawn, + }); + } catch (err) { + logger.error('Failed to withdraw discovery application', err); + ToastActionCreators.createToast({ + type: 'error', + children: Failed to withdraw application. Please try again., + }); + } finally { + setIsWithdrawing(false); + } + }, [guildId, form]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Discovery +

+

+ List your community in Discovery so others can find and join it. +

+
+ + {!eligible && canApply && ( +
+
+
+ +
+
+

+ Not enough members +

+

+ + Your community needs at least {minMemberCount} members before it can be listed in Discovery. + +

+
+
+
+ )} + + {application != null && ( +
+
+ + Status: + + +
+ {application.review_reason && ( +

+ Reason: {application.review_reason} +

+ )} +
+ )} + + {application?.status === DiscoveryApplicationStatus.APPROVED && ( +
+
+
+ +
+

+ + Your community is listed in Discovery. You can update your listing details below or withdraw to remove + it. + +

+
+
+ )} + + {application?.status === DiscoveryApplicationStatus.PENDING && ( +
+
+
+ +
+

+ + Your application is pending review. You can still update your listing details or withdraw the + application. + +

+
+
+ )} + + {(canApply || hasActiveApplication) && ( +
+
+
+
+ Description +
+