diff --git a/client/components/GlobalControls/CreatePubButton.tsx b/client/components/GlobalControls/CreatePubButton.tsx index ae3b8f8e0a..4e6b8ee4ec 100644 --- a/client/components/GlobalControls/CreatePubButton.tsx +++ b/client/components/GlobalControls/CreatePubButton.tsx @@ -58,11 +58,8 @@ const CreatePubButton = () => { evt.preventDefault(); }} ref={formRef} - // only relevant in dev - style={{ display: 'flex', alignItems: 'center', gap: '10px' }} + style={{ position: 'relative', display: 'flex', alignItems: 'center' }} > - - { desktop={{ text: 'Create Pub' }} mobile={{ icon: 'pubDocNew' }} /> +
+ + +
); }; diff --git a/client/components/GlobalControls/GlobalControls.tsx b/client/components/GlobalControls/GlobalControls.tsx index fc7ed0287e..a27deecfff 100644 --- a/client/components/GlobalControls/GlobalControls.tsx +++ b/client/components/GlobalControls/GlobalControls.tsx @@ -85,6 +85,24 @@ const GlobalControls = (props: Props) => { const canCreatePub = loggedIn && (!hideCreatePubButton || canManage); return ( <> + {canSelectCommunityForDevelopment() && ( + + } + /> + )} + {loginData.isSuperAdmin && ( + + )} {canCreatePub && } {renderSearch()} {canView && renderDashboardMenu()} @@ -99,6 +117,24 @@ const GlobalControls = (props: Props) => { if (isBasePubPub) { return ( <> + {canSelectCommunityForDevelopment() && ( + + } + /> + )} + {loginData.isSuperAdmin && ( + + )} {/* { return (
- {canSelectCommunityForDevelopment() && ( - - } - /> - )} {renderItemsVisibleFromCommunity()} {renderBasePubPubLinks()} {renderUserMenuOrLogin()} diff --git a/client/components/GlobalControls/UserMenu.tsx b/client/components/GlobalControls/UserMenu.tsx index df897ef33f..ace9f4e16c 100644 --- a/client/components/GlobalControls/UserMenu.tsx +++ b/client/components/GlobalControls/UserMenu.tsx @@ -2,12 +2,20 @@ import type { MenuDisclosureProps } from 'reakit/Menu'; import type { LoginData } from 'types'; -import React from 'react'; +import React, { useCallback, useState } from 'react'; -import { Button } from '@blueprintjs/core'; +import { Button, Classes } from '@blueprintjs/core'; import { apiFetch } from 'client/utils/apiFetch'; -import { Avatar, Menu, MenuItem, MobileAware, type MobileAwareRenderProps } from 'components'; +import { + Avatar, + Menu, + MenuItem, + MenuItemDivider, + MobileAware, + type MobileAwareRenderProps, +} from 'components'; +import { usePageContext } from 'utils/hooks'; type Props = { loginData: LoginData; @@ -56,15 +64,179 @@ const renderDisclosure = (loginData: LoginData, disclosureProps: MenuDisclosureP ); }; +type UserCommunity = { + id: string; + title: string; + subdomain: string; + domain: string | null; + avatar: string | null; + headerLogo: string | null; + accentColorDark: string | null; +}; + +const getCommunityUrl = (community: UserCommunity, locationData: { isDuqDuq: boolean }) => { + if (locationData.isDuqDuq) { + return `https://${community.subdomain}.duqduq.org`; + } + return community.domain + ? `https://${community.domain}` + : `https://${community.subdomain}.pubpub.org`; +}; + +const getCommunityDisplayUrl = (community: UserCommunity) => { + return community.domain || `${community.subdomain}.pubpub.org`; +}; + +const CommunityAvatar = ({ community, size = 24 }: { community: UserCommunity; size?: number }) => { + const bgColor = community.accentColorDark || '#607D8B'; + const initial = community.title.charAt(0).toUpperCase(); + if (community.avatar) { + return ( + + ); + } + return ( +
+ {initial} +
+ ); +}; + +const SkeletonRow = () => ( +
  • +
    +
    +
    +
    +
    +
  • +); + +const YourCommunitiesSection = ({ communities }: { communities: UserCommunity[] | null }) => { + const { locationData } = usePageContext(); + + // Hide entirely if no communities + if (communities && communities.length === 0) return null; + + return ( + <> +
  • + Your Communities +
  • +
    + {!communities ? ( + <> + + + + + ) : ( + communities.map((community) => ( + } + className="community-menu-item" + menuStyle={{ + display: 'flex', + alignItems: 'center', + }} + text={ +
    +
    + {community.title} +
    +
    + {getCommunityDisplayUrl(community)} +
    +
    + } + /> + )) + )} +
    + + ); +}; + const UserMenu = (props: Props) => { const { loginData } = props; + const [communities, setCommunities] = useState(null); + const [hasFetched, setHasFetched] = useState(false); + + const handleVisibleChange = useCallback( + (visible: boolean) => { + if (visible && !hasFetched) { + setHasFetched(true); + apiFetch + .get('/api/users/communities') + .then(setCommunities) + .catch(() => setCommunities([])); + } + }, + [hasFetched], + ); + return ( renderDisclosure(loginData, disclosureProps)} + onVisibleChange={handleVisibleChange} > { /> + ); }; diff --git a/client/components/GlobalControls/globalControls.scss b/client/components/GlobalControls/globalControls.scss index 8059c67076..09df8e089e 100644 --- a/client/components/GlobalControls/globalControls.scss +++ b/client/components/GlobalControls/globalControls.scss @@ -29,3 +29,7 @@ $bp: vendor.$bp-namespace; @include button-colors; } } + +.bp3-menu-item.community-menu-item { + align-items: center; +} \ No newline at end of file diff --git a/client/components/Menu/MenuItem.tsx b/client/components/Menu/MenuItem.tsx index 117075e31a..96f15668bf 100644 --- a/client/components/Menu/MenuItem.tsx +++ b/client/components/Menu/MenuItem.tsx @@ -4,7 +4,7 @@ import { Classes, Icon } from '@blueprintjs/core'; import classNames from 'classnames'; import * as RK from 'reakit/Menu'; -import { Menu } from './Menu'; +import { Menu, type MenuProps } from './Menu'; import { MenuContext } from './menuContexts'; type SharedMenuItemProps = { @@ -23,6 +23,7 @@ type SharedMenuItemProps = { export type DisplayMenuItemProps = { onDismiss: (...args: any[]) => unknown; hasSubmenu: boolean; + submenuDirection?: 'left' | 'right'; } & SharedMenuItemProps; const DisplayMenuItem = React.forwardRef((props: DisplayMenuItemProps, ref) => { @@ -32,6 +33,7 @@ const DisplayMenuItem = React.forwardRef((props: DisplayMenuItemProps, ref) => { className = '', disabled = false, hasSubmenu = false, + submenuDirection = 'right', href, icon = null, onClick = null, @@ -42,7 +44,11 @@ const DisplayMenuItem = React.forwardRef((props: DisplayMenuItemProps, ref) => { ...restProps } = props; - const label = hasSubmenu ? : rightElement; + const label = hasSubmenu ? ( + + ) : ( + rightElement + ); const onClickWithHref = (evt) => { if (onClick) { @@ -93,18 +99,29 @@ export type MenuItemProps = { text?: React.ReactNode; children?: React.ReactNode; dismissOnClick?: boolean; - placement?: string; + placement?: MenuProps['placement']; + menuStyle?: object; labelElement?: React.ReactNode; } & SharedMenuItemProps; export const MenuItem = React.forwardRef((props: MenuItemProps, ref) => { - const { children = null, text, dismissOnClick = true, ...restProps } = props; + const { + children = null, + text, + dismissOnClick = true, + placement, + menuStyle, + ...restProps + } = props; // @ts-expect-error ts-migrate(2339) FIXME: Property 'dismissMenu' does not exist on type 'nul... Remove this comment to see the full error message const { dismissMenu, parentMenu } = useContext(MenuContext); if (children) { + const submenuPlacement = placement || undefined; return ( ( { {...restProps} style={{ display: 'block', '-webkit-appearance': 'unset' }} hasSubmenu={true} + submenuDirection={submenuPlacement?.startsWith('left') ? 'left' : 'right'} > {text} diff --git a/client/components/index.ts b/client/components/index.ts index a92527a731..f14238c7b7 100644 --- a/client/components/index.ts +++ b/client/components/index.ts @@ -62,6 +62,7 @@ export { MenuButton, MenuConfigProvider, MenuItem, + MenuItemDivider, MenuSelect, type MenuSelectItem, type MenuSelectItems, diff --git a/server/user/api.ts b/server/user/api.ts index dd705b7c01..59716d5939 100644 --- a/server/user/api.ts +++ b/server/user/api.ts @@ -9,6 +9,7 @@ import { wrap } from 'server/wrap'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { isDuqDuq, isProd } from 'utils/environment'; +import { Collection, Community, Member, Pub } from '../models'; import { getPermissions } from './permissions'; import { createUser, getSuggestedEditsUserInfo, updateUser } from './queries'; @@ -100,6 +101,56 @@ router.post('/api/users', async (req, res) => { const uuidParser = z.string().uuid(); +router.get( + '/api/users/communities', + wrap(async (req, res) => { + if (!req.user?.id) { + throw new BadRequestError(); + } + const members = await Member.findAll({ + where: { userId: req.user.id }, + attributes: [], + include: [ + { model: Community, as: 'community', required: false }, + { + model: Pub, + as: 'pub', + required: false, + attributes: [], + include: [{ model: Community, as: 'community' }], + }, + { + model: Collection, + as: 'collection', + required: false, + attributes: [], + include: [{ model: Community, as: 'community' }], + }, + ], + }); + const seen = new Set(); + const communities = members + .map((m) => m.community ?? m.pub?.community ?? m.collection?.community) + .filter((c): c is Community => { + if (!c || seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) + .sort((a, b) => a.title.localeCompare(b.title)) + .map((c) => ({ + id: c.id, + title: c.title, + subdomain: c.subdomain, + domain: c.domain, + avatar: c.avatar, + headerLogo: c.headerLogo, + accentColorDark: c.accentColorDark, + })); + + return res.status(200).json(communities); + }), +); + router.get( '/api/users/:id', wrap(async (req, res) => {