@tribe-nest/forge 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { useEffect, useState, type CSSProperties } from "react";
|
|
2
|
+
import { MessageSquarePlus, Plus, Search, X } from "lucide-react";
|
|
3
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { useCommunitySpaceFeed } from "../../headless/community/useCommunitySpaceFeed";
|
|
5
|
+
import { useCommunitySearch, type CommunityPost, type CommunitySearchResults } from "../../../data/queries/useCommunity";
|
|
6
|
+
import { Loading } from "../Loading";
|
|
7
|
+
import { CommunityComposer } from "./CommunityComposer";
|
|
8
|
+
import { CommunityPostCard } from "./CommunityPostCard";
|
|
9
|
+
|
|
10
|
+
export interface CommunityFeedProps {
|
|
11
|
+
/** A space uuid OR its per-profile slug — resolved from the gated spaces list. */
|
|
12
|
+
spaceId: string;
|
|
13
|
+
onOpenPost: (post: CommunityPost) => void;
|
|
14
|
+
onOpenProfile?: (accountId: string) => void;
|
|
15
|
+
onBack?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A space's post feed, live over the space's Socket.io room. Renders feed
|
|
20
|
+
* spaces as a card stream and forum spaces as compact title rows — same data,
|
|
21
|
+
* two shapes. The composer is hidden by default behind a "+" in the header (it
|
|
22
|
+
* cramps the top otherwise); a header search scopes FTS to THIS space and, while
|
|
23
|
+
* a query is active, replaces the feed with matching posts.
|
|
24
|
+
*/
|
|
25
|
+
export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, onBack }: CommunityFeedProps) {
|
|
26
|
+
const t = useThemeTokens();
|
|
27
|
+
const feed = useCommunitySpaceFeed(idOrSlug);
|
|
28
|
+
const border = `1px solid ${t.border}`;
|
|
29
|
+
|
|
30
|
+
// Composer is hidden by default — the "+" reveals it below the header.
|
|
31
|
+
const [composerOpen, setComposerOpen] = useState(false);
|
|
32
|
+
// Search expands from the header icon; a live query >=2 chars replaces the feed.
|
|
33
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
34
|
+
const [query, setQuery] = useState("");
|
|
35
|
+
const trimmed = query.trim();
|
|
36
|
+
const searching = trimmed.length >= 2;
|
|
37
|
+
|
|
38
|
+
const [searchFocused, setSearchFocused] = useState(false);
|
|
39
|
+
// Debounce the query 500ms so we don't fire an FTS request per keystroke.
|
|
40
|
+
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const id = setTimeout(() => setDebouncedQuery(trimmed), 500);
|
|
43
|
+
return () => clearTimeout(id);
|
|
44
|
+
}, [trimmed]);
|
|
45
|
+
|
|
46
|
+
// Scope search to this space via the resolved space (id or slug both work).
|
|
47
|
+
const search = useCommunitySearch(debouncedQuery, feed.space?.id);
|
|
48
|
+
|
|
49
|
+
if (feed.spacesLoading || (feed.postsLoading && !feed.posts.length)) return <Loading />;
|
|
50
|
+
|
|
51
|
+
if (!feed.space) {
|
|
52
|
+
return (
|
|
53
|
+
<div style={{ border, borderRadius: t.cornerRadius, padding: 32, textAlign: "center", color: t.muted }}>
|
|
54
|
+
This space doesn't exist or your membership doesn't include it.
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isForum = feed.space.type === "forum";
|
|
60
|
+
|
|
61
|
+
const iconButton = (extra?: CSSProperties): CSSProperties => ({
|
|
62
|
+
flexShrink: 0,
|
|
63
|
+
display: "inline-flex",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
justifyContent: "center",
|
|
66
|
+
width: 32,
|
|
67
|
+
height: 32,
|
|
68
|
+
borderRadius: 999,
|
|
69
|
+
border,
|
|
70
|
+
background: "transparent",
|
|
71
|
+
color: t.text,
|
|
72
|
+
cursor: "pointer",
|
|
73
|
+
...extra,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const closeSearch = () => {
|
|
77
|
+
setSearchOpen(false);
|
|
78
|
+
setQuery("");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
83
|
+
<style>{"@keyframes forgeCommunitySearchIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}"}</style>
|
|
84
|
+
<header style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
85
|
+
{onBack && (
|
|
86
|
+
<button
|
|
87
|
+
onClick={onBack}
|
|
88
|
+
aria-label="Back to spaces"
|
|
89
|
+
style={{ background: "none", border: "none", cursor: "pointer", color: t.text, fontSize: 18 }}
|
|
90
|
+
>
|
|
91
|
+
←
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
95
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
96
|
+
<h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text }}>
|
|
97
|
+
{feed.space.name}
|
|
98
|
+
</h2>
|
|
99
|
+
{/* Reveal/hide the composer (hidden by default). */}
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => setComposerOpen((o) => !o)}
|
|
102
|
+
aria-label={composerOpen ? "Hide composer" : "New post"}
|
|
103
|
+
aria-expanded={composerOpen}
|
|
104
|
+
title={composerOpen ? "Hide composer" : isForum ? "Start a discussion" : "New post"}
|
|
105
|
+
style={iconButton({
|
|
106
|
+
width: 26,
|
|
107
|
+
height: 26,
|
|
108
|
+
background: composerOpen ? `${t.primary}1a` : "transparent",
|
|
109
|
+
color: composerOpen ? t.primary : t.text,
|
|
110
|
+
})}
|
|
111
|
+
>
|
|
112
|
+
<Plus size={15} style={{ transform: composerOpen ? "rotate(45deg)" : "none", transition: "transform .15s" }} />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
{feed.space.description && (
|
|
116
|
+
<p style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>{feed.space.description}</p>
|
|
117
|
+
)}
|
|
118
|
+
{feed.memberCount != null && (
|
|
119
|
+
<p style={{ margin: "2px 0 0", fontSize: 12, color: t.muted }}>
|
|
120
|
+
{Number(feed.memberCount)} {Number(feed.memberCount) === 1 ? "member" : "members"}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Search toggle only — the input itself is a full-width bar below the
|
|
126
|
+
header (see below) so it never squeezes the title on a narrow column. */}
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => (searchOpen ? closeSearch() : setSearchOpen(true))}
|
|
129
|
+
aria-label={searchOpen ? "Close search" : "Search this space"}
|
|
130
|
+
aria-expanded={searchOpen}
|
|
131
|
+
title="Search this space"
|
|
132
|
+
style={iconButton({
|
|
133
|
+
background: searchOpen ? `${t.primary}1a` : "transparent",
|
|
134
|
+
color: searchOpen ? t.primary : t.text,
|
|
135
|
+
})}
|
|
136
|
+
>
|
|
137
|
+
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{feed.isJoined && (
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => !feed.joinPending && feed.leave()}
|
|
143
|
+
title="Leave this space (stops new-post notifications)"
|
|
144
|
+
style={{
|
|
145
|
+
flexShrink: 0,
|
|
146
|
+
border: `1px solid ${t.border}`,
|
|
147
|
+
borderRadius: 999,
|
|
148
|
+
background: "transparent",
|
|
149
|
+
color: t.text,
|
|
150
|
+
fontSize: 12,
|
|
151
|
+
fontWeight: 600,
|
|
152
|
+
padding: "5px 14px",
|
|
153
|
+
cursor: feed.joinPending ? "default" : "pointer",
|
|
154
|
+
opacity: feed.joinPending ? 0.6 : 1,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
Joined
|
|
158
|
+
</button>
|
|
159
|
+
)}
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
{/* Full-width search bar — lives below the header so it never distorts
|
|
163
|
+
the title row; focus lifts a soft primary ring. */}
|
|
164
|
+
{searchOpen && (
|
|
165
|
+
<div
|
|
166
|
+
style={{
|
|
167
|
+
display: "flex",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
gap: 10,
|
|
170
|
+
width: "100%",
|
|
171
|
+
padding: "10px 14px",
|
|
172
|
+
borderRadius: 999,
|
|
173
|
+
background: t.surface,
|
|
174
|
+
border: `1px solid ${searchFocused ? t.primary : t.border}`,
|
|
175
|
+
boxShadow: searchFocused ? `0 0 0 3px ${t.primary}22` : "none",
|
|
176
|
+
transition: "border-color .15s, box-shadow .15s",
|
|
177
|
+
animation: "forgeCommunitySearchIn .18s ease",
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<Search size={16} style={{ color: searchFocused ? t.primary : t.muted, flexShrink: 0 }} />
|
|
181
|
+
<input
|
|
182
|
+
autoFocus
|
|
183
|
+
value={query}
|
|
184
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
185
|
+
onFocus={() => setSearchFocused(true)}
|
|
186
|
+
onBlur={() => setSearchFocused(false)}
|
|
187
|
+
onKeyDown={(e) => {
|
|
188
|
+
if (e.key === "Escape") closeSearch();
|
|
189
|
+
}}
|
|
190
|
+
placeholder="Search this space…"
|
|
191
|
+
style={{
|
|
192
|
+
flex: 1,
|
|
193
|
+
minWidth: 0,
|
|
194
|
+
border: "none",
|
|
195
|
+
outline: "none",
|
|
196
|
+
background: "transparent",
|
|
197
|
+
color: t.text,
|
|
198
|
+
fontSize: 14,
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
{query && (
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => setQuery("")}
|
|
204
|
+
aria-label="Clear search"
|
|
205
|
+
style={iconButton({ width: 22, height: 22, border: "none", color: t.muted })}
|
|
206
|
+
>
|
|
207
|
+
<X size={14} />
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Un-joined viewers can still read + post (tier access governs that) —
|
|
214
|
+
the banner nudges them to opt into new-post notifications. */}
|
|
215
|
+
{!feed.isJoined && (
|
|
216
|
+
<div
|
|
217
|
+
style={{
|
|
218
|
+
display: "flex",
|
|
219
|
+
alignItems: "center",
|
|
220
|
+
justifyContent: "space-between",
|
|
221
|
+
gap: 10,
|
|
222
|
+
border: `1px solid ${t.primary}33`,
|
|
223
|
+
background: `${t.primary}0d`,
|
|
224
|
+
borderRadius: t.cornerRadius,
|
|
225
|
+
padding: "8px 12px",
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<span style={{ fontSize: 13, color: t.text }}>Join this space to get notified about new posts.</span>
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => !feed.joinPending && feed.join()}
|
|
231
|
+
style={{
|
|
232
|
+
flexShrink: 0,
|
|
233
|
+
border: "none",
|
|
234
|
+
borderRadius: 999,
|
|
235
|
+
background: t.primary,
|
|
236
|
+
color: t.textPrimary,
|
|
237
|
+
fontSize: 12,
|
|
238
|
+
fontWeight: 600,
|
|
239
|
+
padding: "5px 14px",
|
|
240
|
+
cursor: feed.joinPending ? "default" : "pointer",
|
|
241
|
+
opacity: feed.joinPending ? 0.6 : 1,
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
Join
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Composer — hidden until toggled; behavior/props identical to before. */}
|
|
250
|
+
{composerOpen && (
|
|
251
|
+
<div style={{ position: "relative" }}>
|
|
252
|
+
<CommunityComposer
|
|
253
|
+
withTitle={isForum}
|
|
254
|
+
placeholder={isForum ? "Start a discussion…" : "Share something with the community…"}
|
|
255
|
+
submitLabel={isForum ? "Start discussion" : "Post"}
|
|
256
|
+
compact={!isForum}
|
|
257
|
+
onSubmit={async (input) => {
|
|
258
|
+
await feed.createPost.mutateAsync(input);
|
|
259
|
+
setComposerOpen(false);
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
<button
|
|
263
|
+
onClick={() => setComposerOpen(false)}
|
|
264
|
+
aria-label="Close composer"
|
|
265
|
+
title="Close composer"
|
|
266
|
+
style={{
|
|
267
|
+
position: "absolute",
|
|
268
|
+
top: 8,
|
|
269
|
+
right: 8,
|
|
270
|
+
display: "inline-flex",
|
|
271
|
+
alignItems: "center",
|
|
272
|
+
justifyContent: "center",
|
|
273
|
+
width: 26,
|
|
274
|
+
height: 26,
|
|
275
|
+
borderRadius: 999,
|
|
276
|
+
border: "none",
|
|
277
|
+
background: "transparent",
|
|
278
|
+
color: t.muted,
|
|
279
|
+
cursor: "pointer",
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<X size={15} />
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{/* While searching, results replace the feed; clearing returns to it. */}
|
|
288
|
+
{searching ? (
|
|
289
|
+
<SearchResults
|
|
290
|
+
results={search.data}
|
|
291
|
+
// Treat the debounce window (query typed but not yet requested) as
|
|
292
|
+
// loading so we never flash "no matches" before the request fires.
|
|
293
|
+
loading={search.isLoading || search.isFetching || debouncedQuery !== trimmed}
|
|
294
|
+
query={debouncedQuery || trimmed}
|
|
295
|
+
isForum={isForum}
|
|
296
|
+
onOpenPost={onOpenPost}
|
|
297
|
+
spaceId={feed.space.id}
|
|
298
|
+
/>
|
|
299
|
+
) : feed.posts.length === 0 ? (
|
|
300
|
+
<div
|
|
301
|
+
style={{
|
|
302
|
+
border,
|
|
303
|
+
borderRadius: t.cornerRadius,
|
|
304
|
+
padding: "44px 24px",
|
|
305
|
+
textAlign: "center",
|
|
306
|
+
display: "flex",
|
|
307
|
+
flexDirection: "column",
|
|
308
|
+
alignItems: "center",
|
|
309
|
+
gap: 6,
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
<div
|
|
313
|
+
style={{
|
|
314
|
+
display: "inline-flex",
|
|
315
|
+
alignItems: "center",
|
|
316
|
+
justifyContent: "center",
|
|
317
|
+
width: 48,
|
|
318
|
+
height: 48,
|
|
319
|
+
borderRadius: 999,
|
|
320
|
+
background: `${t.primary}14`,
|
|
321
|
+
color: t.primary,
|
|
322
|
+
marginBottom: 6,
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<MessageSquarePlus size={22} />
|
|
326
|
+
</div>
|
|
327
|
+
<div style={{ fontSize: 16, fontWeight: 600, color: t.text }}>
|
|
328
|
+
{isForum ? "No discussions yet" : "No posts yet"}
|
|
329
|
+
</div>
|
|
330
|
+
<div style={{ fontSize: 13, color: t.muted, maxWidth: 300 }}>
|
|
331
|
+
{isForum
|
|
332
|
+
? "Kick things off — start the first discussion in this space."
|
|
333
|
+
: "Be the first to share something with the community."}
|
|
334
|
+
</div>
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => setComposerOpen(true)}
|
|
337
|
+
style={{
|
|
338
|
+
marginTop: 10,
|
|
339
|
+
border: "none",
|
|
340
|
+
borderRadius: 999,
|
|
341
|
+
background: t.primary,
|
|
342
|
+
color: t.textPrimary,
|
|
343
|
+
fontSize: 13,
|
|
344
|
+
fontWeight: 600,
|
|
345
|
+
padding: "8px 18px",
|
|
346
|
+
cursor: "pointer",
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{isForum ? "Start a discussion" : "Write the first post"}
|
|
350
|
+
</button>
|
|
351
|
+
</div>
|
|
352
|
+
) : (
|
|
353
|
+
<div style={{ display: "flex", flexDirection: "column", gap: isForum ? 8 : 14 }}>
|
|
354
|
+
{feed.posts.map((post) => (
|
|
355
|
+
<CommunityPostCard
|
|
356
|
+
key={post.id}
|
|
357
|
+
post={post}
|
|
358
|
+
variant={isForum ? "forum" : "feed"}
|
|
359
|
+
onOpen={onOpenPost}
|
|
360
|
+
onOpenProfile={onOpenProfile}
|
|
361
|
+
onReact={(emoji) => feed.react(post.id, emoji)}
|
|
362
|
+
/>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
{!searching && feed.hasMore && (
|
|
368
|
+
<button
|
|
369
|
+
onClick={() => feed.fetchNextPage()}
|
|
370
|
+
style={{
|
|
371
|
+
alignSelf: "center",
|
|
372
|
+
background: "none",
|
|
373
|
+
border,
|
|
374
|
+
borderRadius: t.cornerRadius,
|
|
375
|
+
padding: "6px 14px",
|
|
376
|
+
cursor: "pointer",
|
|
377
|
+
color: t.text,
|
|
378
|
+
fontSize: 13,
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
Load more
|
|
382
|
+
</button>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* In-space search results — post matches (title + snippet) that navigate to the
|
|
390
|
+
* thread via onOpenPost, and reply matches surfaced by their parent post. Search
|
|
391
|
+
* rows are lean FTS projections, so we hand onOpenPost a minimal post shape
|
|
392
|
+
* (callers route by id).
|
|
393
|
+
*/
|
|
394
|
+
function SearchResults({
|
|
395
|
+
results,
|
|
396
|
+
loading,
|
|
397
|
+
query,
|
|
398
|
+
isForum,
|
|
399
|
+
onOpenPost,
|
|
400
|
+
spaceId,
|
|
401
|
+
}: {
|
|
402
|
+
results: CommunitySearchResults | undefined;
|
|
403
|
+
loading: boolean;
|
|
404
|
+
query: string;
|
|
405
|
+
isForum: boolean;
|
|
406
|
+
onOpenPost: (post: CommunityPost) => void;
|
|
407
|
+
spaceId: string;
|
|
408
|
+
}) {
|
|
409
|
+
const t = useThemeTokens();
|
|
410
|
+
const border = `1px solid ${t.border}`;
|
|
411
|
+
|
|
412
|
+
if (loading) return <Loading />;
|
|
413
|
+
|
|
414
|
+
const posts = results?.posts ?? [];
|
|
415
|
+
const replies = results?.replies ?? [];
|
|
416
|
+
|
|
417
|
+
if (posts.length === 0 && replies.length === 0) {
|
|
418
|
+
return (
|
|
419
|
+
<div style={{ border, borderRadius: t.cornerRadius, padding: 28, textAlign: "center", color: t.muted }}>
|
|
420
|
+
No matches for “{query}”.
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const snippet = (text: string | null, len = 140) => {
|
|
426
|
+
const clean = (text ?? "").replace(/\s+/g, " ").trim();
|
|
427
|
+
return clean.length > len ? clean.slice(0, len) + "…" : clean;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const openById = (id: string, title: string | null, bodyText: string | null) =>
|
|
431
|
+
// Minimal post shape — the FTS row carries no engagement/media; callers only
|
|
432
|
+
// read the id to route, and the thread view refetches the full post.
|
|
433
|
+
onOpenPost({
|
|
434
|
+
id,
|
|
435
|
+
spaceId,
|
|
436
|
+
title,
|
|
437
|
+
bodyText,
|
|
438
|
+
profileId: "",
|
|
439
|
+
authorAccountId: "",
|
|
440
|
+
body: null,
|
|
441
|
+
isPinned: false,
|
|
442
|
+
isLocked: false,
|
|
443
|
+
replyCount: 0,
|
|
444
|
+
reactionCount: 0,
|
|
445
|
+
lastActivityAt: null,
|
|
446
|
+
createdAt: new Date().toISOString(),
|
|
447
|
+
author: null,
|
|
448
|
+
media: [],
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const rowStyle: CSSProperties = {
|
|
452
|
+
textAlign: "left",
|
|
453
|
+
width: "100%",
|
|
454
|
+
border,
|
|
455
|
+
borderRadius: t.cornerRadius,
|
|
456
|
+
background: t.surface,
|
|
457
|
+
padding: "12px 14px",
|
|
458
|
+
cursor: "pointer",
|
|
459
|
+
color: t.text,
|
|
460
|
+
display: "flex",
|
|
461
|
+
flexDirection: "column",
|
|
462
|
+
gap: 4,
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<div style={{ display: "flex", flexDirection: "column", gap: isForum ? 8 : 10 }}>
|
|
467
|
+
{posts.map((p) => (
|
|
468
|
+
<button key={`post-${p.id}`} onClick={() => openById(p.id, p.title, p.bodyText)} style={rowStyle}>
|
|
469
|
+
<span style={{ fontWeight: 700, fontSize: 15 }}>{p.title || snippet(p.bodyText, 60) || "Untitled"}</span>
|
|
470
|
+
{p.bodyText && (
|
|
471
|
+
<span style={{ fontSize: 13, color: t.muted }}>{snippet(p.bodyText)}</span>
|
|
472
|
+
)}
|
|
473
|
+
</button>
|
|
474
|
+
))}
|
|
475
|
+
{replies.map((r) => (
|
|
476
|
+
<button key={`reply-${r.id}`} onClick={() => openById(r.postId, r.postTitle, null)} style={rowStyle}>
|
|
477
|
+
<span style={{ fontSize: 12, color: t.muted }}>Reply in “{r.postTitle || "a discussion"}”</span>
|
|
478
|
+
{r.bodyText && <span style={{ fontSize: 13, color: t.text }}>{snippet(r.bodyText)}</span>}
|
|
479
|
+
</button>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Trophy } from "lucide-react";
|
|
3
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
5
|
+
import {
|
|
6
|
+
communityMemberName,
|
|
7
|
+
useCommunityLeaderboard,
|
|
8
|
+
type CommunityLeaderboardPeriod,
|
|
9
|
+
} from "../../../data/queries/useCommunity";
|
|
10
|
+
import { Loading } from "../Loading";
|
|
11
|
+
import { LevelBadge, MemberAvatar } from "./MemberAvatar";
|
|
12
|
+
|
|
13
|
+
const PERIODS: { value: CommunityLeaderboardPeriod; label: string }[] = [
|
|
14
|
+
{ value: "7", label: "This week" },
|
|
15
|
+
{ value: "30", label: "This month" },
|
|
16
|
+
{ value: "all", label: "All time" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export interface CommunityLeaderboardProps {
|
|
20
|
+
onOpenProfile?: (accountId: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The points leaderboard with 7-day / 30-day / all-time tabs. */
|
|
24
|
+
export function CommunityLeaderboard({ onOpenProfile }: CommunityLeaderboardProps) {
|
|
25
|
+
const t = useThemeTokens();
|
|
26
|
+
const { user } = usePublicAuth();
|
|
27
|
+
const [period, setPeriod] = useState<CommunityLeaderboardPeriod>("7");
|
|
28
|
+
const { data: entries = [], isLoading } = useCommunityLeaderboard(period);
|
|
29
|
+
const border = `1px solid ${t.border}`;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
33
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap" }}>
|
|
34
|
+
<h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text, display: "flex", alignItems: "center", gap: 8 }}>
|
|
35
|
+
<Trophy size={18} style={{ color: t.primary }} /> Leaderboard
|
|
36
|
+
</h2>
|
|
37
|
+
<div style={{ display: "flex", gap: 6 }}>
|
|
38
|
+
{PERIODS.map((p) => {
|
|
39
|
+
const active = p.value === period;
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={p.value}
|
|
43
|
+
onClick={() => setPeriod(p.value)}
|
|
44
|
+
style={{
|
|
45
|
+
background: active ? t.primary : "transparent",
|
|
46
|
+
color: active ? t.textPrimary : t.text,
|
|
47
|
+
border: active ? "none" : border,
|
|
48
|
+
borderRadius: 999,
|
|
49
|
+
padding: "4px 12px",
|
|
50
|
+
fontSize: 13,
|
|
51
|
+
fontWeight: 600,
|
|
52
|
+
cursor: "pointer",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{p.label}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{isLoading ? (
|
|
63
|
+
<Loading />
|
|
64
|
+
) : entries.length === 0 ? (
|
|
65
|
+
<div style={{ border, borderRadius: t.cornerRadius, padding: 28, textAlign: "center", color: t.muted }}>
|
|
66
|
+
No activity in this period yet.
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
70
|
+
{entries.map((entry, i) => {
|
|
71
|
+
const isMe = entry.accountId === user?.id;
|
|
72
|
+
const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : null;
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
key={entry.accountId}
|
|
76
|
+
onClick={() => onOpenProfile?.(entry.accountId)}
|
|
77
|
+
role={onOpenProfile ? "button" : undefined}
|
|
78
|
+
style={{
|
|
79
|
+
display: "flex",
|
|
80
|
+
alignItems: "center",
|
|
81
|
+
gap: 12,
|
|
82
|
+
border: `1px solid ${isMe ? `${t.primary}66` : t.border}`,
|
|
83
|
+
background: isMe ? `${t.primary}0d` : t.surface,
|
|
84
|
+
borderRadius: t.cornerRadius,
|
|
85
|
+
padding: "10px 14px",
|
|
86
|
+
cursor: onOpenProfile ? "pointer" : "default",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span style={{ width: 28, textAlign: "center", fontWeight: 700, color: t.muted, fontSize: medal ? 18 : 14 }}>
|
|
90
|
+
{medal ?? i + 1}
|
|
91
|
+
</span>
|
|
92
|
+
<MemberAvatar member={entry} size={34} />
|
|
93
|
+
<span style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 6 }}>
|
|
94
|
+
<span style={{ fontWeight: 600, color: t.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
95
|
+
{communityMemberName(entry)}
|
|
96
|
+
{isMe ? " (you)" : ""}
|
|
97
|
+
</span>
|
|
98
|
+
<LevelBadge level={entry.level} />
|
|
99
|
+
</span>
|
|
100
|
+
<span style={{ fontWeight: 800, color: t.primary }}>{entry.points}</span>
|
|
101
|
+
<span style={{ fontSize: 11, color: t.muted }}>pts</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|