From ecb90b84a201458c1380c19ac2da7862342082a5 Mon Sep 17 00:00:00 2001 From: suprstarrd Date: Sat, 7 Mar 2026 00:28:38 -0500 Subject: [PATCH 1/5] fix: create `icon_paths` field for communities This allows subcommunities to have their own icons, and also allows for more granular control over where icons exist in the future. There are a few minor but more complicated exceptions where this addition is not respected yet. Signed-off-by: Sienna "suprstarrd" M. --- .../src/models/communities.js | 27 ++++++++++++++++++ .../services/juxt-web/routes/admin/admin.tsx | 8 ++++++ .../juxt-web/views/ctr/communityListView.tsx | 5 ++-- .../juxt-web/views/ctr/communityView.tsx | 7 ++--- .../ctr/components/ui/CtrCommunityIcon.tsx | 21 ++++++++++++++ .../views/ctr/components/ui/CtrMiiIcon.tsx | 1 - .../views/portal/communityListView.tsx | 6 ++-- .../juxt-web/views/portal/communityView.tsx | 8 ++---- .../components/ui/PortalCommunityIcon.tsx | 21 ++++++++++++++ .../views/web/admin/editCommunityView.tsx | 8 +++++- .../web/components/ui/WebCommunityIcon.tsx | 28 +++++++++++++++++++ 11 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon.tsx create mode 100644 apps/juxtaposition-ui/src/services/juxt-web/views/portal/components/ui/PortalCommunityIcon.tsx create mode 100644 apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebCommunityIcon.tsx diff --git a/apps/juxtaposition-ui/src/models/communities.js b/apps/juxtaposition-ui/src/models/communities.js index e7159c0d..b81713ba 100644 --- a/apps/juxtaposition-ui/src/models/communities.js +++ b/apps/juxtaposition-ui/src/models/communities.js @@ -19,6 +19,29 @@ const PermissionsSchema = new Schema({ } }); +const IconPathsSchema = new Schema({ + 32: { + type: String, + required: true + }, + 48: { + type: String, + required: true + }, + 64: { + type: String, + required: true + }, + 96: { + type: String, + required: true + }, + 128: { + type: String, + required: true + } +}); + export const CommunitySchema = new Schema({ platform_id: { type: Number, @@ -81,6 +104,10 @@ export const CommunitySchema = new Schema({ }, ctr_header: { type: String }, wup_header: { type: String }, + icon_paths: { + type: IconPathsSchema, + required: true + }, title_ids: { type: [String], default: undefined diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index ccd219cb..beb0e66a 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -451,6 +451,13 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo return res.sendStatus(422); } + const iconPaths = { + 32: icons.icon32, + 48: icons.icon48, + 64: icons.icon64, + 96: icons.icon96, + 128: icons.icon128 + }; const document = { platform_id: body.platform, name: body.name, @@ -467,6 +474,7 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo icon: icons.tgaBlob, ctr_header: headers.ctr, wup_header: headers.wup, + icon_paths: iconPaths, title_id: body.title_ids, community_id: communityId, olive_community_id: communityId, diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityListView.tsx index f0186c36..f12bc449 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityListView.tsx @@ -1,17 +1,16 @@ import { t } from 'i18next'; import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; -import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { CtrCommunityIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon'; import type { ReactNode } from 'react'; import type { CommunityItemProps, CommunityListViewProps, CommunityOverviewViewProps } from '@/services/juxt-web/views/web/communityListView'; export function CtrCommunityItem(props: CommunityItemProps): ReactNode { - const url = useUrl(); const id = props.community.olive_community_id; return (
  • - +
    {props.community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityView.tsx index de168da3..43dd211c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/communityView.tsx @@ -3,13 +3,14 @@ import { CtrPageBody, CtrRoot } from '@/services/juxt-web/views/ctr/root'; import { CtrPostListClosedView } from '@/services/juxt-web/views/ctr/postList'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { CtrCommunityIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon'; import type { ReactNode } from 'react'; import type { CommunityViewProps } from '@/services/juxt-web/views/web/communityView'; export function CtrCommunityView(props: CommunityViewProps): ReactNode { const url = useUrl(); const community = props.community; - const { bannerUrl, imageId, legacy } = url.ctrHeader(community); + const { bannerUrl, legacy } = url.ctrHeader(community); return ( @@ -28,9 +29,7 @@ export function CtrCommunityView(props: CommunityViewProps): ReactNode { >

    - - - + {community.name} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon.tsx new file mode 100644 index 00000000..2a4e784b --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrCommunityIcon.tsx @@ -0,0 +1,21 @@ +import { CtrIcon } from '@/services/juxt-web/views/ctr/components/ui/CtrIcon'; +import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; +import type { ReactNode } from 'react'; +import type { CommunityIconProps } from '@/services/juxt-web/views/web/components/ui/WebCommunityIcon'; + +export function CtrCommunityIcon(props: CommunityIconProps): ReactNode { + const url = useUrl(); + const imageId = props.community.parent ? props.community.parent : props.community.olive_community_id; + const iconUrl = props.community.icon_paths ? url.cdn(props.community.icon_paths[props.size]) : url.cdn(`/icons/${imageId}/${props.size}.png`); + const href = `/communities/${props.community.community_id}`; + + return ( + + + ); +} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrMiiIcon.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrMiiIcon.tsx index 42eef521..2ff223ef 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrMiiIcon.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/ctr/components/ui/CtrMiiIcon.tsx @@ -5,7 +5,6 @@ import type { MiiIconProps } from '@/services/juxt-web/views/web/components/ui/W export function CtrMiiIcon(props: MiiIconProps): ReactNode { const url = useUrl(); - const miiUrl = props.face_url ?? url.cdn(`/mii/${props.pid}/normal_face.png`); const href = `/users/${props.pid}`; const type = props.type ?? 'mii-icon'; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityListView.tsx index 2e527929..77f064a4 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityListView.tsx @@ -1,18 +1,16 @@ import { t } from 'i18next'; import { PortalPageBody, PortalRoot } from '@/services/juxt-web/views/portal/root'; import { PortalNavBar } from '@/services/juxt-web/views/portal/navbar'; -import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { PortalCommunityIcon } from '@/services/juxt-web/views/portal/components/ui/PortalCommunityIcon'; import type { ReactNode } from 'react'; import type { CommunityItemProps, CommunityListViewProps, CommunityOverviewViewProps } from '@/services/juxt-web/views/web/communityListView'; export function PortalCommunityItem(props: CommunityItemProps): ReactNode { - const url = useUrl(); const id = props.community.olive_community_id; - const imageCommunityId = props.community.parent ? props.community.parent : id; return (
  • - +
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityView.tsx index 8cfc0888..6b57b6f4 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/communityView.tsx @@ -4,6 +4,7 @@ import { PortalNavBar } from '@/services/juxt-web/views/portal/navbar'; import { PortalPostListClosedView } from '@/services/juxt-web/views/portal/postList'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; +import { PortalCommunityIcon } from '@/services/juxt-web/views/portal/components/ui/PortalCommunityIcon'; import { PortalUIIcon } from '@/services/juxt-web/views/portal/components/ui/PortalUIIcon'; import type { ReactNode } from 'react'; import type { CommunityViewProps } from '@/services/juxt-web/views/web/communityView'; @@ -45,12 +46,7 @@ export function PortalCommunityView(props: CommunityViewProps): ReactNode {
    - + {community.icon_paths + ? ( + + ) + : ( + + )}
    diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebCommunityIcon.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebCommunityIcon.tsx new file mode 100644 index 00000000..f7e8082c --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/components/ui/WebCommunityIcon.tsx @@ -0,0 +1,28 @@ +import { WebIcon } from '@/services/juxt-web/views/web/components/ui/WebIcon'; +import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; +import type { ReactNode } from 'react'; +import type { InferSchemaType } from 'mongoose'; +import type { CommunitySchema } from '@/models/communities'; + +export type CommunityIconProps = { + community: InferSchemaType; + size: '32' | '48' | '64' | '96' | '128'; + className?: string; +}; + +export function WebCommunityIcon(props: CommunityIconProps): ReactNode { + const url = useUrl(); + const imageId = props.community.parent ? props.community.parent : props.community.olive_community_id; + const iconUrl = props.community.icon_paths ? url.cdn(props.community.icon_paths[props.size]) : url.cdn(`/icons/${imageId}/${props.size}.png`); + const href = `/communities/${props.community.community_id}`; + + return ( + + + ); +} From 9b58aae824894080da87e3d075f836d122188194 Mon Sep 17 00:00:00 2001 From: suprstarrd Date: Mon, 23 Mar 2026 18:53:27 -0400 Subject: [PATCH 2/5] fix(ui): make icon_paths not required in the model Signed-off-by: Sienna "suprstarrd" M. --- apps/juxtaposition-ui/src/models/communities.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/juxtaposition-ui/src/models/communities.js b/apps/juxtaposition-ui/src/models/communities.js index b81713ba..7b25cb4b 100644 --- a/apps/juxtaposition-ui/src/models/communities.js +++ b/apps/juxtaposition-ui/src/models/communities.js @@ -104,10 +104,7 @@ export const CommunitySchema = new Schema({ }, ctr_header: { type: String }, wup_header: { type: String }, - icon_paths: { - type: IconPathsSchema, - required: true - }, + icon_paths: { type: IconPathsSchema }, title_ids: { type: [String], default: undefined From dbfe0873595e5b897a92a9b38fef3d99114ddab1 Mon Sep 17 00:00:00 2001 From: suprstarrd Date: Mon, 23 Mar 2026 18:54:19 -0400 Subject: [PATCH 3/5] fix(api): add icon_paths to model Signed-off-by: Sienna "suprstarrd" M. --- apps/miiverse-api/src/models/community.ts | 26 ++++++++++++++++++- .../src/types/mongoose/community.ts | 9 +++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/miiverse-api/src/models/community.ts b/apps/miiverse-api/src/models/community.ts index a844058c..31171866 100644 --- a/apps/miiverse-api/src/models/community.ts +++ b/apps/miiverse-api/src/models/community.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import { Schema, model } from 'mongoose'; import { MongoError } from 'mongodb'; import type { CommunityData } from '@/types/miiverse/community'; -import type { ICommunity, ICommunityMethods, CommunityModel, ICommunityPermissions, HydratedCommunityDocument, ICommunityInput } from '@/types/mongoose/community'; +import type { ICommunity, ICommunityMethods, CommunityModel, ICommunityPermissions, HydratedCommunityDocument, ICommunityInput, IIconPaths } from '@/types/mongoose/community'; const PermissionsSchema = new Schema({ open: { @@ -23,6 +23,29 @@ const PermissionsSchema = new Schema({ } }); +const IconPathsSchema = new Schema({ + 32: { + type: String, + required: true + }, + 48: { + type: String, + required: true + }, + 64: { + type: String, + required: true + }, + 96: { + type: String, + required: true + }, + 128: { + type: String, + required: true + } +}); + /* Constraints here (default, required etc.) apply to new documents being added * See ICommunity for expected shape of query results * If you add default: or required:, please also update ICommunity and ICommunityInput! @@ -90,6 +113,7 @@ const CommunitySchema = new Schema Date: Mon, 23 Mar 2026 19:01:50 -0400 Subject: [PATCH 4/5] merge: add icon_paths to communities.ts Signed-off-by: Sienna "suprstarrd" M. --- .../src/models/communities.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/juxtaposition-ui/src/models/communities.ts b/apps/juxtaposition-ui/src/models/communities.ts index ee9898c9..5aadd6bf 100644 --- a/apps/juxtaposition-ui/src/models/communities.ts +++ b/apps/juxtaposition-ui/src/models/communities.ts @@ -8,6 +8,14 @@ enum COMMUNITY_TYPE { Private = 3 } +export interface IIconPaths { + 32: string; + 48: string; + 64: string; + 96: string; + 128: string; +} + export interface ICommunityPermissions { open: boolean; minimum_new_post_access_level: number; @@ -38,6 +46,7 @@ export interface ICommunity { icon: string; ctr_header?: string; wup_header?: string; + icon_paths?: IIconPaths; /** @deprecated Does not actually exist on any community. Use title_id */ title_ids?: string[]; // Does not exist on any community title_id: string[]; @@ -102,6 +111,29 @@ export const PermissionsSchema = new Schema({ } }); +const IconPathsSchema = new Schema({ + 32: { + type: String, + required: true + }, + 48: { + type: String, + required: true + }, + 64: { + type: String, + required: true + }, + 96: { + type: String, + required: true + }, + 128: { + type: String, + required: true + } +}); + /* Constraints here (default, required etc.) apply to new documents being added * See ICommunity for expected shape of query results * If you add default: or required:, please also update ICommunity and ICommunityInput! @@ -171,6 +203,7 @@ export const CommunitySchema = new Schema Date: Wed, 25 Mar 2026 12:22:56 +0100 Subject: [PATCH 5/5] fix: add icon_paths to community update (plus a little extra typesafety) --- .../services/juxt-web/routes/admin/admin.tsx | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index 891e4d8b..c0f2b274 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -19,6 +19,7 @@ import { WebNewCommunityView } from '@/services/juxt-web/views/web/admin/newComm import { WebEditCommunityView } from '@/services/juxt-web/views/web/admin/editCommunityView'; import { WebModerateUserView } from '@/services/juxt-web/views/web/admin/moderateUserView'; import { zodCommaSeperatedList } from '@/services/juxt-web/routes/schemas'; +import type { ICommunityInput, IIconPaths } from '@/models/communities'; import type { ReportWithPost } from '@/services/juxt-web/views/web/admin/reportListView'; import type { HydratedSettingsDocument } from '@/models/settings'; import type { HydratedReportDocument } from '@/models/report'; @@ -452,15 +453,15 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo return res.sendStatus(422); } - const iconPaths = { + const iconPaths: IIconPaths = { 32: icons.icon32, 48: icons.icon48, 64: icons.icon64, 96: icons.icon96, 128: icons.icon128 }; - const document = { - platform_id: body.platform, + const document: ICommunityInput = { + platform_id: Number(body.platform), name: body.name, description: body.description, open: true, @@ -490,19 +491,19 @@ adminRouter.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCo updateCommunityHash(newCommunity); - const communityType = getCommunityType(document.type); - const communityPlatform = getCommunityPlatform(document.platform_id); + const communityType = getCommunityType(newCommunity.type); + const communityPlatform = getCommunityPlatform(newCommunity.platform_id); const changes = []; - changes.push(`Name set to "${document.name}"`); - changes.push(`Description set to "${document.description}"`); + changes.push(`Name set to "${newCommunity.name}"`); + changes.push(`Description set to "${newCommunity.description}"`); changes.push(`Platform ID set to "${communityPlatform}"`); changes.push(`Type set to "${communityType}"`); - changes.push(`Title IDs set to "${document.title_id.join(', ')}"`); - changes.push(`Parent set to "${document.parent}"`); - changes.push(`App data set to "${document.app_data}"`); - changes.push(`Is Recommended set to "${document.is_recommended}"`); - changes.push(`Has Shop Page set to "${document.has_shop_page}"`); + changes.push(`Title IDs set to "${newCommunity.title_id.join(', ')}"`); + changes.push(`Parent set to "${newCommunity.parent}"`); + changes.push(`App data set to "${newCommunity.app_data}"`); + changes.push(`Is Recommended set to "${newCommunity.is_recommended}"`); + changes.push(`Has Shop Page set to "${newCommunity.has_shop_page}"`); const fields = [ 'name', @@ -610,11 +611,21 @@ adminRouter.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCo } } - const document = { + const iconPaths: IIconPaths | undefined = icons + ? { + 32: icons.icon32, + 48: icons.icon48, + 64: icons.icon64, + 96: icons.icon96, + 128: icons.icon128 + } + : undefined; + const document: Partial = { type: body.type, has_shop_page: body.has_shop_page, - platform_id: body.platform, + platform_id: Number(body.platform), icon: icons?.tgaBlob ?? oldCommunity.icon, + icon_paths: iconPaths, ctr_header: headers?.ctr ?? oldCommunity.ctr_header, wup_header: headers?.wup ?? oldCommunity.wup_header, title_id: body.title_ids,