From dcd216ef478b3b5577ac4fb5c0ce2ec991b2b62e Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 22 Apr 2026 23:04:06 -0400 Subject: [PATCH 1/4] Update header bar with more helpful buttons, cleaner layout, and community list --- .../GlobalControls/CreatePubButton.tsx | 18 +- .../GlobalControls/GlobalControls.tsx | 48 +++-- client/components/GlobalControls/UserMenu.tsx | 174 +++++++++++++++++- client/components/Menu/MenuItem.tsx | 26 ++- client/components/index.ts | 1 + server/user/api.ts | 61 ++++++ 6 files changed, 305 insertions(+), 23 deletions(-) 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..04c3a6d68b 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,8 +64,166 @@ 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) => ( + } + 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 ( { // The z-index of the PubHeaderFormatting is 19 menuStyle={{ zIndex: 20 }} disclosure={(disclosureProps) => renderDisclosure(loginData, disclosureProps)} + onVisibleChange={handleVisibleChange} > { /> + ); }; 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..fba874951f 100644 --- a/server/user/api.ts +++ b/server/user/api.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import passport from 'passport'; +import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { verifyCaptchaPayload } from 'server/utils/captcha'; @@ -9,6 +10,7 @@ import { wrap } from 'server/wrap'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { isDuqDuq, isProd } from 'utils/environment'; +import { sequelize } from '../models'; import { getPermissions } from './permissions'; import { createUser, getSuggestedEditsUserInfo, updateUser } from './queries'; @@ -100,6 +102,65 @@ 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(); + } + // Raw SQL rather than Sequelize ORM because the 5-way UNION across Members, + // Pubs, Collections, PubAttributions, and CollectionAttributions is a single + // efficient query. The ORM alternative would require 5+ separate queries with + // JS deduplication. Mirrors the pattern in uniqueCommunitiesFromMembersQuery.ts. + const communities = (await sequelize.query( + `SELECT + "Communities"."id", + "Communities"."title", + "Communities"."subdomain", + "Communities"."domain", + "Communities"."avatar", + "Communities"."headerLogo", + "Communities"."accentColorDark" + FROM "Communities" + INNER JOIN ( + SELECT DISTINCT "communityId" FROM "Members" + WHERE "userId" = :userId AND "communityId" IS NOT NULL + UNION + SELECT DISTINCT "Pubs"."communityId" FROM "Pubs" + INNER JOIN "Members" ON "Pubs"."id" = "Members"."pubId" + WHERE "Members"."userId" = :userId AND "Pubs"."communityId" IS NOT NULL + UNION + SELECT DISTINCT "Collections"."communityId" FROM "Collections" + INNER JOIN "Members" ON "Collections"."id" = "Members"."collectionId" + WHERE "Members"."userId" = :userId AND "Collections"."communityId" IS NOT NULL + UNION + SELECT DISTINCT "Collections"."communityId" FROM "Collections" + INNER JOIN "CollectionAttributions" ON "Collections"."id" = "CollectionAttributions"."collectionId" + WHERE "CollectionAttributions"."userId" = :userId AND "Collections"."communityId" IS NOT NULL + UNION + SELECT DISTINCT "Pubs"."communityId" FROM "Pubs" + INNER JOIN "PubAttributions" ON "Pubs"."id" = "PubAttributions"."pubId" + WHERE "PubAttributions"."userId" = :userId AND "Pubs"."communityId" IS NOT NULL + ) AS "UniqueCommunities" ON "Communities"."id" = "UniqueCommunities"."communityId" + ORDER BY "Communities"."title" ASC`, + { + replacements: { userId: req.user.id }, + type: QueryTypes.SELECT, + }, + )) as { + id: string; + title: string; + subdomain: string; + domain: string | null; + avatar: string | null; + headerLogo: string | null; + accentColorDark: string | null; + }[]; + + return res.status(200).json(communities); + }), +); + router.get( '/api/users/:id', wrap(async (req, res) => { From b7c84ae93a2366a2ca031a17b66ff776a8ba1afc Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 22 Apr 2026 23:13:24 -0400 Subject: [PATCH 2/4] Simplify (and correct) query --- server/user/api.ts | 90 +++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 50 deletions(-) diff --git a/server/user/api.ts b/server/user/api.ts index fba874951f..59716d5939 100644 --- a/server/user/api.ts +++ b/server/user/api.ts @@ -1,6 +1,5 @@ import { Router } from 'express'; import passport from 'passport'; -import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { verifyCaptchaPayload } from 'server/utils/captcha'; @@ -10,7 +9,7 @@ import { wrap } from 'server/wrap'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { isDuqDuq, isProd } from 'utils/environment'; -import { sequelize } from '../models'; +import { Collection, Community, Member, Pub } from '../models'; import { getPermissions } from './permissions'; import { createUser, getSuggestedEditsUserInfo, updateUser } from './queries'; @@ -108,54 +107,45 @@ router.get( if (!req.user?.id) { throw new BadRequestError(); } - // Raw SQL rather than Sequelize ORM because the 5-way UNION across Members, - // Pubs, Collections, PubAttributions, and CollectionAttributions is a single - // efficient query. The ORM alternative would require 5+ separate queries with - // JS deduplication. Mirrors the pattern in uniqueCommunitiesFromMembersQuery.ts. - const communities = (await sequelize.query( - `SELECT - "Communities"."id", - "Communities"."title", - "Communities"."subdomain", - "Communities"."domain", - "Communities"."avatar", - "Communities"."headerLogo", - "Communities"."accentColorDark" - FROM "Communities" - INNER JOIN ( - SELECT DISTINCT "communityId" FROM "Members" - WHERE "userId" = :userId AND "communityId" IS NOT NULL - UNION - SELECT DISTINCT "Pubs"."communityId" FROM "Pubs" - INNER JOIN "Members" ON "Pubs"."id" = "Members"."pubId" - WHERE "Members"."userId" = :userId AND "Pubs"."communityId" IS NOT NULL - UNION - SELECT DISTINCT "Collections"."communityId" FROM "Collections" - INNER JOIN "Members" ON "Collections"."id" = "Members"."collectionId" - WHERE "Members"."userId" = :userId AND "Collections"."communityId" IS NOT NULL - UNION - SELECT DISTINCT "Collections"."communityId" FROM "Collections" - INNER JOIN "CollectionAttributions" ON "Collections"."id" = "CollectionAttributions"."collectionId" - WHERE "CollectionAttributions"."userId" = :userId AND "Collections"."communityId" IS NOT NULL - UNION - SELECT DISTINCT "Pubs"."communityId" FROM "Pubs" - INNER JOIN "PubAttributions" ON "Pubs"."id" = "PubAttributions"."pubId" - WHERE "PubAttributions"."userId" = :userId AND "Pubs"."communityId" IS NOT NULL - ) AS "UniqueCommunities" ON "Communities"."id" = "UniqueCommunities"."communityId" - ORDER BY "Communities"."title" ASC`, - { - replacements: { userId: req.user.id }, - type: QueryTypes.SELECT, - }, - )) as { - id: string; - title: string; - subdomain: string; - domain: string | null; - avatar: string | null; - headerLogo: string | null; - accentColorDark: string | null; - }[]; + 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); }), From 7badef72b96cc4548c8e4b39328f0928a4a169d9 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 23 Apr 2026 14:01:58 +0200 Subject: [PATCH 3/4] fix: consistent width of usermenu --- client/components/GlobalControls/UserMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/GlobalControls/UserMenu.tsx b/client/components/GlobalControls/UserMenu.tsx index 04c3a6d68b..9fe9633b91 100644 --- a/client/components/GlobalControls/UserMenu.tsx +++ b/client/components/GlobalControls/UserMenu.tsx @@ -229,7 +229,7 @@ const UserMenu = (props: Props) => { aria-label="User menu" placement="bottom-end" // The z-index of the PubHeaderFormatting is 19 - menuStyle={{ zIndex: 20 }} + menuStyle={{ zIndex: 20, width: 245 }} disclosure={(disclosureProps) => renderDisclosure(loginData, disclosureProps)} onVisibleChange={handleVisibleChange} > From d317a9d93fdb42bc38dc7d557a623dc544eaa391 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 23 Apr 2026 14:08:54 +0200 Subject: [PATCH 4/4] fix: align logo with text --- client/components/GlobalControls/UserMenu.tsx | 5 +++++ client/components/GlobalControls/globalControls.scss | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/client/components/GlobalControls/UserMenu.tsx b/client/components/GlobalControls/UserMenu.tsx index 9fe9633b91..ace9f4e16c 100644 --- a/client/components/GlobalControls/UserMenu.tsx +++ b/client/components/GlobalControls/UserMenu.tsx @@ -173,6 +173,11 @@ const YourCommunitiesSection = ({ communities }: { communities: UserCommunity[] key={community.id} href={getCommunityUrl(community, locationData)} icon={} + className="community-menu-item" + menuStyle={{ + display: 'flex', + alignItems: 'center', + }} text={