From 3ddba8cad28e80e64c9d6335f428bf8fdae18ceb Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sat, 12 Sep 2026 13:33:52 -0700 Subject: [PATCH 1/5] Move text-post queries into src/data/text The Teia Text queries and their hooks lived in src/data/swr.js, a 1.4k-line file of unrelated hooks. They landed there with the blog in March, before the repo settled on per-feature data modules; poll comments, wiki and curations all since got a src/data// folder of their own. Text now follows that layout: queries.ts holds the GraphQL documents, hooks.ts the SWR hooks, and index.ts re-exports both so pages import from '@data/text'. The hooks keep their SWR keys and options exactly, so caching and what each feed returns are unchanged. useMultisigAddresses stays in swr.js since New Post uses it too, and roles.ts already marks it for consolidation with the role hooks. Co-Authored-By: Claude Opus 5 --- src/data/swr.js | 131 ------------------------------- src/data/text/hooks.ts | 56 +++++++++++++ src/data/text/index.ts | 2 + src/data/text/queries.ts | 81 +++++++++++++++++++ src/pages/profile/text-posts.jsx | 2 +- src/pages/text/Community.jsx | 2 +- src/pages/text/OfficialPosts.jsx | 2 +- src/pages/text/YourPosts.jsx | 2 +- 8 files changed, 143 insertions(+), 135 deletions(-) create mode 100644 src/data/text/hooks.ts create mode 100644 src/data/text/index.ts create mode 100644 src/data/text/queries.ts diff --git a/src/data/swr.js b/src/data/swr.js index 53ebd4b65..79cdb3631 100644 --- a/src/data/swr.js +++ b/src/data/swr.js @@ -9,7 +9,6 @@ import { COPYRIGHT_CONTRACT, DAO_TREASURY_CONTRACT, HEN_CONTRACT_FA2, - TEIA_MULTISIG_BLOG_TAG, BAKING_BAD_BAKERS_API, } from '@constants' import { @@ -18,8 +17,6 @@ import { BaseTokenFieldsFragment, fetchGraphQL, } from '@data/api' -import laggy from '@utils/swr-laggy-middleware' -import { useModerators } from '@data/roles' import { ACTIVITY_EVENT_TYPES, MINT_TYPES, @@ -977,110 +974,6 @@ export function useTopDonors(excludeAddresses = []) { return { data, mutate, error, isLoading: !data && !error } } -const TEXT_POSTS_QUERY = gql` - ${BaseTokenFieldsFragment} - query TextPosts($limit: Int!) { - tokens( - where: { - _or: [ - { mime_type: { _eq: "text/plain" } } - { mime_type: { _eq: "text/markdown" } } - ] - editions: { _gt: 0 } - metadata_status: { _eq: "processed" } - fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } - } - order_by: { minted_at: desc } - limit: $limit - ) { - ...baseTokenFields - } - } -` - -export function useTextPosts(limit = 100) { - return useSWR( - ['text-community'], - () => - request(import.meta.env.VITE_TEIA_GRAPHQL_API, TEXT_POSTS_QUERY, { - limit, - }), - { - revalidateIfStale: false, - revalidateOnFocus: false, - use: [laggy], - } - ) -} - -const TEXT_POSTS_BY_ARTIST_QUERY = gql` - ${BaseTokenFieldsFragment} - query TextPostsByArtist($address: String!) { - tokens( - where: { - artist_address: { _eq: $address } - _or: [ - { mime_type: { _eq: "text/plain" } } - { mime_type: { _eq: "text/markdown" } } - ] - editions: { _gt: 0 } - metadata_status: { _eq: "processed" } - fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } - } - order_by: { minted_at: desc } - ) { - ...baseTokenFields - listings(where: { status: { _eq: "active" } }, order_by: { price: asc }) { - seller_address - } - holdings(where: { amount: { _gt: "0" } }) { - holder_address - amount - } - } - } -` - -export function useTextPostsByArtist(address) { - return useSWR( - address ? ['text-posts-by-artist', address] : null, - () => - request( - import.meta.env.VITE_TEIA_GRAPHQL_API, - TEXT_POSTS_BY_ARTIST_QUERY, - { address } - ), - { - revalidateIfStale: false, - revalidateOnFocus: false, - use: [laggy], - } - ) -} - -const OFFICIAL_TEXT_POSTS_QUERY = gql` - ${BaseTokenFieldsFragment} - query OfficialTextPosts($addresses: [String!], $tag: String!, $limit: Int!) { - tokens( - where: { - artist_address: { _in: $addresses } - tags: { tag: { _eq: $tag } } - _or: [ - { mime_type: { _eq: "text/plain" } } - { mime_type: { _eq: "text/markdown" } } - ] - editions: { _gt: 0 } - metadata_status: { _eq: "processed" } - fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } - } - order_by: { minted_at: desc } - limit: $limit - ) { - ...baseTokenFields - } - } -` - export function useMultisigAddresses() { const { data } = useSWR( `/v1/contracts/${DAO_TREASURY_CONTRACT}/storage?path=users`, @@ -1090,30 +983,6 @@ export function useMultisigAddresses() { return data || [] } -export function useOfficialTextPosts(limit = 100) { - const multisigAddresses = useMultisigAddresses() - const { data: moderators } = useModerators() - const addresses = [...new Set([...multisigAddresses, ...(moderators || [])])] - return useSWR( - addresses.length > 0 ? ['text-official', addresses] : null, - () => - request( - import.meta.env.VITE_TEIA_GRAPHQL_API, - OFFICIAL_TEXT_POSTS_QUERY, - { - addresses, - tag: TEIA_MULTISIG_BLOG_TAG, - limit, - } - ), - { - revalidateIfStale: false, - revalidateOnFocus: false, - use: [laggy], - } - ) -} - const USER_ACTIVITY_QUERY = gql` query UserActivity( $address: String! diff --git a/src/data/text/hooks.ts b/src/data/text/hooks.ts new file mode 100644 index 000000000..c2a8ab40a --- /dev/null +++ b/src/data/text/hooks.ts @@ -0,0 +1,56 @@ +// SWR hooks for Teia Text feeds. + +import useSWR from 'swr' +import { request } from 'graphql-request' +import { TEIA_MULTISIG_BLOG_TAG } from '@constants' +import { useModerators } from '@data/roles' +import { useMultisigAddresses } from '@data/swr' +import laggy from '@utils/swr-laggy-middleware' +import { + TEXT_POSTS_QUERY, + TEXT_POSTS_BY_ARTIST_QUERY, + OFFICIAL_TEXT_POSTS_QUERY, +} from './queries' + +const GRAPHQL_API = import.meta.env.VITE_TEIA_GRAPHQL_API + +const FEED_OPTIONS = { + revalidateIfStale: false, + revalidateOnFocus: false, + use: [laggy], +} + +/** Community feed: the latest posts from anyone. */ +export function useTextPosts(limit = 100) { + return useSWR( + ['text-community'], + () => request(GRAPHQL_API, TEXT_POSTS_QUERY, { limit }), + FEED_OPTIONS + ) +} + +/** One author's posts (profile Text tab, Your Posts). */ +export function useTextPostsByArtist(address?: string) { + return useSWR( + address ? ['text-posts-by-artist', address] : null, + () => request(GRAPHQL_API, TEXT_POSTS_BY_ARTIST_QUERY, { address }), + FEED_OPTIONS + ) +} + +/** Bulletin feed: tagged posts from multisig members and moderators. */ +export function useOfficialTextPosts(limit = 100) { + const multisigAddresses: string[] = useMultisigAddresses() + const { data: moderators } = useModerators() + const addresses = [...new Set([...multisigAddresses, ...(moderators || [])])] + return useSWR( + addresses.length > 0 ? ['text-official', addresses] : null, + () => + request(GRAPHQL_API, OFFICIAL_TEXT_POSTS_QUERY, { + addresses, + tag: TEIA_MULTISIG_BLOG_TAG, + limit, + }), + FEED_OPTIONS + ) +} diff --git a/src/data/text/index.ts b/src/data/text/index.ts new file mode 100644 index 000000000..2c9036902 --- /dev/null +++ b/src/data/text/index.ts @@ -0,0 +1,2 @@ +export * from './queries' +export * from './hooks' diff --git a/src/data/text/queries.ts b/src/data/text/queries.ts new file mode 100644 index 000000000..b12122397 --- /dev/null +++ b/src/data/text/queries.ts @@ -0,0 +1,81 @@ +// GraphQL documents for Teia Text (the blog). Every feed lists text/plain and +// text/markdown OBJKTs on the HEN contract; they differ only in whose posts. + +import { gql } from 'graphql-request' +import { HEN_CONTRACT_FA2 } from '@constants' +import { BaseTokenFieldsFragment } from '@data/api' + +/** Community: the latest posts from anyone. */ +export const TEXT_POSTS_QUERY = gql` + ${BaseTokenFieldsFragment} + query TextPosts($limit: Int!) { + tokens( + where: { + _or: [ + { mime_type: { _eq: "text/plain" } } + { mime_type: { _eq: "text/markdown" } } + ] + editions: { _gt: 0 } + metadata_status: { _eq: "processed" } + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + } + order_by: { minted_at: desc } + limit: $limit + ) { + ...baseTokenFields + } + } +` + +/** One author's posts, with the holdings/listings the burn controls need. */ +export const TEXT_POSTS_BY_ARTIST_QUERY = gql` + ${BaseTokenFieldsFragment} + query TextPostsByArtist($address: String!) { + tokens( + where: { + artist_address: { _eq: $address } + _or: [ + { mime_type: { _eq: "text/plain" } } + { mime_type: { _eq: "text/markdown" } } + ] + editions: { _gt: 0 } + metadata_status: { _eq: "processed" } + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + } + order_by: { minted_at: desc } + ) { + ...baseTokenFields + listings(where: { status: { _eq: "active" } }, order_by: { price: asc }) { + seller_address + } + holdings(where: { amount: { _gt: "0" } }) { + holder_address + amount + } + } + } +` + +/** Bulletin: tagged posts from multisig members and moderators. */ +export const OFFICIAL_TEXT_POSTS_QUERY = gql` + ${BaseTokenFieldsFragment} + query OfficialTextPosts($addresses: [String!], $tag: String!, $limit: Int!) { + tokens( + where: { + artist_address: { _in: $addresses } + tags: { tag: { _eq: $tag } } + _or: [ + { mime_type: { _eq: "text/plain" } } + { mime_type: { _eq: "text/markdown" } } + ] + editions: { _gt: 0 } + metadata_status: { _eq: "processed" } + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + } + order_by: { minted_at: desc } + limit: $limit + ) { + ...baseTokenFields + } + } +` diff --git a/src/pages/profile/text-posts.jsx b/src/pages/profile/text-posts.jsx index c14785383..b16923a1b 100644 --- a/src/pages/profile/text-posts.jsx +++ b/src/pages/profile/text-posts.jsx @@ -2,7 +2,7 @@ import { Link, useOutletContext } from 'react-router-dom' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { useUserStore } from '@context/userStore' -import { useTextPostsByArtist } from '@data/swr' +import { useTextPostsByArtist } from '@data/text' import { TextPostCard } from '@components/text/TextPostCard' import styles from './text-posts.module.scss' diff --git a/src/pages/text/Community.jsx b/src/pages/text/Community.jsx index e3643a954..998a02864 100644 --- a/src/pages/text/Community.jsx +++ b/src/pages/text/Community.jsx @@ -1,4 +1,4 @@ -import { useTextPosts } from '@data/swr' +import { useTextPosts } from '@data/text' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { TextPostCard } from '../../components/text/TextPostCard' diff --git a/src/pages/text/OfficialPosts.jsx b/src/pages/text/OfficialPosts.jsx index 709643c92..aedd963a5 100644 --- a/src/pages/text/OfficialPosts.jsx +++ b/src/pages/text/OfficialPosts.jsx @@ -1,4 +1,4 @@ -import { useOfficialTextPosts } from '@data/swr' +import { useOfficialTextPosts } from '@data/text' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { TextPostCard } from '../../components/text/TextPostCard' diff --git a/src/pages/text/YourPosts.jsx b/src/pages/text/YourPosts.jsx index 5bd29bc3c..947c6ad08 100644 --- a/src/pages/text/YourPosts.jsx +++ b/src/pages/text/YourPosts.jsx @@ -2,7 +2,7 @@ import { Link, Navigate } from 'react-router-dom' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { useUserStore } from '@context/userStore' -import { useTextPostsByArtist } from '@data/swr' +import { useTextPostsByArtist } from '@data/text' import { TextPostCard } from '../../components/text/TextPostCard' import styles from '@style' From 59d95fd386a75444552119da287fc044c56d707e Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sat, 12 Sep 2026 13:34:12 -0700 Subject: [PATCH 2/5] Add a TEIA Members feed to Text Text had Community (anyone) and Bulletin (multisig and moderators), but no way to read what the people holding a stake in Teia are writing. TEIA Members lists posts whose author currently holds TEIA, using the same holder set that drives the profile's TEIA HOLDER badge. It filters on the indexer with `artist_address: { _in: holders }` rather than filtering Community's posts in the browser: against the live indexer that returns the latest 100 holder posts (back to Oct 2025) in under a second, where filtering Community's latest 100 would find only 34. The SWR key uses the holder set's size instead of its ~2.6k addresses, and isLoading is derived from data/error because SWR v1 has no isLoading. Co-Authored-By: Claude Opus 5 --- src/data/text/hooks.ts | 27 ++++++++++++++++++++- src/data/text/queries.ts | 23 ++++++++++++++++++ src/index.jsx | 2 ++ src/pages/text/MemberPosts.jsx | 44 ++++++++++++++++++++++++++++++++++ src/pages/text/index.jsx | 1 + 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/pages/text/MemberPosts.jsx diff --git a/src/data/text/hooks.ts b/src/data/text/hooks.ts index c2a8ab40a..fd544f722 100644 --- a/src/data/text/hooks.ts +++ b/src/data/text/hooks.ts @@ -3,13 +3,14 @@ import useSWR from 'swr' import { request } from 'graphql-request' import { TEIA_MULTISIG_BLOG_TAG } from '@constants' -import { useModerators } from '@data/roles' +import { useModerators, useTokenHolders } from '@data/roles' import { useMultisigAddresses } from '@data/swr' import laggy from '@utils/swr-laggy-middleware' import { TEXT_POSTS_QUERY, TEXT_POSTS_BY_ARTIST_QUERY, OFFICIAL_TEXT_POSTS_QUERY, + HOLDER_TEXT_POSTS_QUERY, } from './queries' const GRAPHQL_API = import.meta.env.VITE_TEIA_GRAPHQL_API @@ -54,3 +55,27 @@ export function useOfficialTextPosts(limit = 100) { FEED_OPTIONS ) } + +/** + * TEIA Members feed: text posts whose author currently holds TEIA, from the + * same holder set that drives the profile's TEIA HOLDER badge. Filtered + * server-side so the feed gets the latest `limit` holder posts, not just the + * holders among recent posts. + */ +export function useHolderTextPosts(limit = 100) { + const { data: holders, error: holdersError } = useTokenHolders() + const result = useSWR( + // Keyed on the set's size rather than ~2.6k addresses, so SWR isn't hashing + // a huge key on every render. + holders?.size ? ['text-holders', holders.size, limit] : null, + () => + request(GRAPHQL_API, HOLDER_TEXT_POSTS_QUERY, { + addresses: [...(holders as Set)], + limit, + }), + FEED_OPTIONS + ) + const error = result.error || holdersError + // SWR v1 has no `isLoading`: loading until the posts or an error arrive. + return { ...result, error, isLoading: !error && !result.data } +} diff --git a/src/data/text/queries.ts b/src/data/text/queries.ts index b12122397..1abfbed16 100644 --- a/src/data/text/queries.ts +++ b/src/data/text/queries.ts @@ -79,3 +79,26 @@ export const OFFICIAL_TEXT_POSTS_QUERY = gql` } } ` + +/** TEIA Members: posts from current TEIA token holders. */ +export const HOLDER_TEXT_POSTS_QUERY = gql` + ${BaseTokenFieldsFragment} + query HolderTextPosts($addresses: [String!], $limit: Int!) { + tokens( + where: { + artist_address: { _in: $addresses } + _or: [ + { mime_type: { _eq: "text/plain" } } + { mime_type: { _eq: "text/markdown" } } + ] + editions: { _gt: 0 } + metadata_status: { _eq: "processed" } + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + } + order_by: { minted_at: desc } + limit: $limit + ) { + ...baseTokenFields + } + } +` diff --git a/src/index.jsx b/src/index.jsx index 3a539d3fe..0f4cddad1 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -12,6 +12,7 @@ import Text from '@pages/text' import Calendar from '@pages/calendar' import CalendarEvent from '@pages/calendar/event' import Community from '@pages/text/Community' +import MemberPosts from '@pages/text/MemberPosts' import OfficialPosts from '@pages/text/OfficialPosts' import YourPosts from '@pages/text/YourPosts' import NewPost from '@pages/text/NewPost' @@ -225,6 +226,7 @@ const router = createBrowserRouter( } /> }> } /> + } /> } /> } /> } /> diff --git a/src/pages/text/MemberPosts.jsx b/src/pages/text/MemberPosts.jsx new file mode 100644 index 000000000..def0c0fd3 --- /dev/null +++ b/src/pages/text/MemberPosts.jsx @@ -0,0 +1,44 @@ +import { useHolderTextPosts } from '@data/text' +import { Container } from '@atoms/layout' +import { Loading } from '@atoms/loading' +import { TextPostCard } from '../../components/text/TextPostCard' +import styles from '@style' + +export default function MemberPosts() { + const { data, error, isLoading } = useHolderTextPosts(100) + + if (error) { + return ( + +

Error loading TEIA member posts: {error.message}

+
+ ) + } + + if (isLoading || !data) { + return + } + + const posts = data.tokens || [] + + if (posts.length === 0) { + return ( +
+

No text posts from TEIA members yet.

+
+ ) + } + + return ( + <> +

+ Posts from members who currently hold TEIA tokens. +

+
+ {posts.map((nft) => ( + + ))} +
+ + ) +} diff --git a/src/pages/text/index.jsx b/src/pages/text/index.jsx index 131a27a53..a61248ce9 100644 --- a/src/pages/text/index.jsx +++ b/src/pages/text/index.jsx @@ -6,6 +6,7 @@ import styles from '@style' const TABS = [ { title: 'Community', to: '' }, + { title: 'TEIA Members', to: 'members' }, { title: 'Bulletin', to: 'bulletin' }, { title: 'Your Posts', to: 'yourposts', private: true }, { title: 'New Post', to: 'newpost', private: true }, From e390a68ae1b3e242f5bf9e3ed347243a9384f562 Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sat, 12 Sep 2026 13:34:30 -0700 Subject: [PATCH 3/5] Show each Text author's role, with a legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing on a Text post said whether its author was a core member, a moderator, or a TEIA holder, even though those roles already decide who can post to Bulletin and are shown as badges on profiles. Every post card now puts one emoji in front of the author: ๐Ÿ“ƒ multisig core member, ๐Ÿ› ๏ธ moderator, or ๐Ÿช™ TEIA token holder. Rights grow in that order, so only the highest applies. It reads the shared role sets through useAccountRoles, so it adds no per-post requests, and it carries a title and aria-label with the role name. Community, TEIA Members and Bulletin explain the emoji in their description. The legend is built from the same list as the emoji, so the two can't drift. Checked against the live multisig, moderator and holder lists: 0 wrong badges across Community (100 posts), TEIA Members (100) and Bulletin (6). Co-Authored-By: Claude Opus 5 --- src/components/text/TextPostCard.jsx | 24 +++++---- src/components/text/TextPostCard.module.scss | 7 +++ src/components/user-badges/RoleBadges.jsx | 51 ++++++++++++++++++++ src/components/user-badges/index.js | 7 ++- src/pages/text/Community.jsx | 16 ++++-- src/pages/text/MemberPosts.jsx | 2 + src/pages/text/OfficialPosts.jsx | 2 + src/pages/text/index.module.scss | 6 +++ 8 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/components/text/TextPostCard.jsx b/src/components/text/TextPostCard.jsx index 165ca8b6c..874844215 100644 --- a/src/components/text/TextPostCard.jsx +++ b/src/components/text/TextPostCard.jsx @@ -4,6 +4,7 @@ import { PATH, MARKETPLACE_CONTRACT_TEIA } from '@constants' import { HashToURL } from '@utils' import { useUserStore } from '@context/userStore' import { useModalStore } from '@context/modalStore' +import { RoleEmoji } from '@components/user-badges' import styles from './TextPostCard.module.scss' // Format date to readable string @@ -132,16 +133,19 @@ export function TextPostCard({ nft, showBurn = false }) {

{getExcerpt(description)}

- - {authorName} - + + + + {authorName} + + {formatDate(minted_at)}
diff --git a/src/components/text/TextPostCard.module.scss b/src/components/text/TextPostCard.module.scss index 64159c898..fe73bcf95 100644 --- a/src/components/text/TextPostCard.module.scss +++ b/src/components/text/TextPostCard.module.scss @@ -83,6 +83,13 @@ color: var(--secondary-color); } +// Role emoji + author, kept together so .meta's 16px gap doesn't split them. +.author { + display: inline-flex; + align-items: center; + gap: 6px; +} + .author_link { font-weight: 500; color: var(--secondary-color); diff --git a/src/components/user-badges/RoleBadges.jsx b/src/components/user-badges/RoleBadges.jsx index f7dfcba57..669191dda 100644 --- a/src/components/user-badges/RoleBadges.jsx +++ b/src/components/user-badges/RoleBadges.jsx @@ -27,6 +27,57 @@ export function RoleBadgesView({ roles, className }) { ) } +/** + * Roles from most to fewest rights: a multisig core member outranks a + * moderator, who outranks a token holder. Only the highest one is shown. + */ +const ROLE_EMOJI = [ + { key: 'isMultisig', emoji: '๐Ÿ“ƒ', label: 'Multisig core member' }, + { key: 'isModerator', emoji: '๐Ÿ› ๏ธ', label: 'Moderator' }, + { key: 'isTokenHolder', emoji: '๐Ÿช™', label: 'TEIA token holder' }, +] + +/** + * Key to the role emoji, read from the same list the badges use so the two + * cannot drift apart. Listed from fewest to most rights. + */ +export function RoleLegend({ className }) { + return ( + + {[...ROLE_EMOJI].reverse().map(({ key, emoji, label }, i) => ( + + {i > 0 && ' ยท '} + {' '} + {label} + + ))} + {' โ€” each author shows their highest role.'} + + ) +} + +/** + * A single emoji for an address's highest role, for placing in front of an + * alias or wallet address. Renders nothing when the address has no role. + */ +export function RoleEmoji({ address, className }) { + const roles = useAccountRoles(address) + const top = ROLE_EMOJI.find(({ key }) => roles[key]) + if (!top) return null + return ( + + {top.emoji} + + ) +} + /** * Self-fetching role badges for a single address. Drop in anywhere an account * is shown, the three role sets are cached app-wide, so this costs no diff --git a/src/components/user-badges/index.js b/src/components/user-badges/index.js index 7f67e4459..b1c45ae15 100644 --- a/src/components/user-badges/index.js +++ b/src/components/user-badges/index.js @@ -1 +1,6 @@ -export { default as RoleBadges, RoleBadgesView } from './RoleBadges' +export { + default as RoleBadges, + RoleBadgesView, + RoleEmoji, + RoleLegend, +} from './RoleBadges' diff --git a/src/pages/text/Community.jsx b/src/pages/text/Community.jsx index 998a02864..1589fb5d1 100644 --- a/src/pages/text/Community.jsx +++ b/src/pages/text/Community.jsx @@ -2,6 +2,7 @@ import { useTextPosts } from '@data/text' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { TextPostCard } from '../../components/text/TextPostCard' +import { RoleLegend } from '@components/user-badges' import styles from '@style' export default function Community() { @@ -30,10 +31,15 @@ export default function Community() { } return ( -
- {posts.map((nft) => ( - - ))} -
+ <> +

+ +

+
+ {posts.map((nft) => ( + + ))} +
+ ) } diff --git a/src/pages/text/MemberPosts.jsx b/src/pages/text/MemberPosts.jsx index def0c0fd3..c6a3326d6 100644 --- a/src/pages/text/MemberPosts.jsx +++ b/src/pages/text/MemberPosts.jsx @@ -2,6 +2,7 @@ import { useHolderTextPosts } from '@data/text' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { TextPostCard } from '../../components/text/TextPostCard' +import { RoleLegend } from '@components/user-badges' import styles from '@style' export default function MemberPosts() { @@ -33,6 +34,7 @@ export default function MemberPosts() { <>

Posts from members who currently hold TEIA tokens. +

{posts.map((nft) => ( diff --git a/src/pages/text/OfficialPosts.jsx b/src/pages/text/OfficialPosts.jsx index aedd963a5..7ad3c8bb1 100644 --- a/src/pages/text/OfficialPosts.jsx +++ b/src/pages/text/OfficialPosts.jsx @@ -2,6 +2,7 @@ import { useOfficialTextPosts } from '@data/text' import { Container } from '@atoms/layout' import { Loading } from '@atoms/loading' import { TextPostCard } from '../../components/text/TextPostCard' +import { RoleLegend } from '@components/user-badges' import styles from '@style' export default function OfficialPosts() { @@ -34,6 +35,7 @@ export default function OfficialPosts() {

Official updates from TEIA's core members and moderators on community events and developments. +

{posts.map((nft) => ( diff --git a/src/pages/text/index.module.scss b/src/pages/text/index.module.scss index 2f6ae8c61..3640ed7e8 100644 --- a/src/pages/text/index.module.scss +++ b/src/pages/text/index.module.scss @@ -24,6 +24,12 @@ text-align: center; } +// Role emoji key, on its own line under a feed's description. +.legend { + display: block; + margin-top: 4px; +} + .posts_list { display: flex; flex-direction: column; From 828b692b87c94b6670542c8c836f60cf95cb21a4 Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sat, 12 Sep 2026 13:34:46 -0700 Subject: [PATCH 4/5] Label the Embed Token button in the post editor The editor toolbar's token embed was an image icon with its name only in a hover tooltip, so it read as a generic "insert image" button and people couldn't tell it embeds a Teia OBJKT. It now shows "Embed Token" next to the icon. The button stays the same height as the rest of the toolbar, the label isn't clipped, and clicking it still opens the token search. Co-Authored-By: Claude Opus 5 --- src/components/text/TokenSearchCommand.jsx | 16 ++++++++++------ .../text/TokenSearchCommand.module.scss | 9 +++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/text/TokenSearchCommand.jsx b/src/components/text/TokenSearchCommand.jsx index bd1dbb63d..e4a1cc62f 100644 --- a/src/components/text/TokenSearchCommand.jsx +++ b/src/components/text/TokenSearchCommand.jsx @@ -205,13 +205,17 @@ function TokenSearchDropdown({ close, textApi }) { export const tokenSearchCommand = commands.group([], { name: 'teia-token', groupName: 'teia-token', + // The icon alone read as a generic image button, so it carries a label. icon: ( - - - + + + + + Embed Token + ), children: (props) => , buttonProps: { 'aria-label': 'Embed Teia Token', title: 'Embed Teia Token' }, diff --git a/src/components/text/TokenSearchCommand.module.scss b/src/components/text/TokenSearchCommand.module.scss index 5815469a2..4cf8f00df 100644 --- a/src/components/text/TokenSearchCommand.module.scss +++ b/src/components/text/TokenSearchCommand.module.scss @@ -174,3 +174,12 @@ border-radius: 2px; } } + +// Toolbar trigger: icon plus a visible "Embed Token" label. +.trigger { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; + font-size: 12px; +} From f4945b9cf5a7a835f2041492ded8f7ddb36fe8ad Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sat, 12 Sep 2026 13:34:49 -0700 Subject: [PATCH 5/5] Fit the profile Text tab inside the page margins Every other profile tab keeps its content inside the responsive side padding that feeds get from .feed_container (15px, 40px on tablet, 60px on desktop). The Text tab's post list had none, so at 1440px its cards ran the full 1436px, edge to edge, while Collection sat at 60 to 1376. The list now uses the same padding and 1800px max width, and its cards line up exactly with Collection's content at 375, 768 and 1440px. Co-Authored-By: Claude Opus 5 --- src/pages/profile/text-posts.module.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pages/profile/text-posts.module.scss b/src/pages/profile/text-posts.module.scss index 1e75d546e..139119720 100644 --- a/src/pages/profile/text-posts.module.scss +++ b/src/pages/profile/text-posts.module.scss @@ -1,8 +1,21 @@ +@use '@styles/layout' as *; + +// Same side padding the other profile feeds get from .feed_container, so the +// posts sit inside the page margins instead of running edge to edge. .posts_list { display: flex; flex-direction: column; gap: var(--spacing); width: 100%; + max-width: 1800px; + margin: 0 auto; + padding: 0 15px; + @include respond-to('tablet') { + padding: 0 40px; + } + @include respond-to('desktop') { + padding: 0 60px; + } } .empty {