Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/web/app/api/explore/feed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

import { getGovernanceHomeSpaceContext } from '~/app/home/governance-home-space-ids';
import type { BrowseSidebarData } from '~/core/browse/fetch-browse-sidebar-data';
import { fetchBrowseSidebarData } from '~/core/browse/fetch-browse-sidebar-data';
import {
resolveMemberSpaceFromWalletSafe,
} from '~/core/browse/resolve-member-space-from-wallet';
import { WALLET_ADDRESS } from '~/core/cookie';
import {
type ExploreSort,
type ExploreTime,
fetchExploreFeed,
} from '~/core/explore/fetch-explore-feed';

function normId(id: string): string {
return id.replace(/-/g, '').toLowerCase();
}

const SORTS: ExploreSort[] = ['new', 'top', 'controversial'];
const TIMES: ExploreTime[] = ['today', 'week', 'month', 'year', 'all'];

function parseSort(raw: string | null): ExploreSort {
if (raw && (SORTS as string[]).includes(raw)) return raw as ExploreSort;
return 'top';
}

function parseTime(raw: string | null): ExploreTime {
if (raw && (TIMES as string[]).includes(raw)) return raw as ExploreTime;
return 'week';
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const sort = parseSort(searchParams.get('sort'));
const time = parseTime(searchParams.get('time'));
const spaceId = searchParams.get('spaceId');
const cursor = searchParams.get('cursor');

const cookieWallet = (await cookies()).get(WALLET_ADDRESS)?.value;

let personalMemberSpaceId: string | null = null;
let memberOrEditorSpaceIds: string[] = [];

if (cookieWallet) {
personalMemberSpaceId = await resolveMemberSpaceFromWalletSafe(cookieWallet);
if (personalMemberSpaceId) {
try {
const ctx = await getGovernanceHomeSpaceContext(personalMemberSpaceId);
memberOrEditorSpaceIds = [...new Set([...ctx.editorIds, ...ctx.myProposalSpaceIds, personalMemberSpaceId])];
} catch {
memberOrEditorSpaceIds = [personalMemberSpaceId];
}
}
}

let browse: BrowseSidebarData;
try {
browse = await fetchBrowseSidebarData(personalMemberSpaceId);
} catch {
try {
browse = await fetchBrowseSidebarData(null);
} catch {
browse = {
featured: [],
editorOf: [],
memberOf: [],
documentationImage: null,
personalSpaceId: null,
};
}
}

let spaceFilter: string | null = null;
if (spaceId && spaceId !== 'all') {
const want = normId(spaceId);
const match = [...browse.featured, ...browse.editorOf, ...browse.memberOf].find(r => normId(r.id) === want);
if (match) spaceFilter = match.id;
}

try {
const result = await fetchExploreFeed({
browse,
sort,
time,
spaceFilterId: spaceFilter,
cursor,
walletAddress: cookieWallet ?? null,
memberOrEditorSpaceIds,
});
return NextResponse.json(result);
} catch (e) {
console.error('explore feed', e);
/** Degraded response so the Explore UI still mounts when GraphQL is down; client shows empty feed. */
return NextResponse.json({ items: [], nextCursor: null });
}
}
45 changes: 45 additions & 0 deletions apps/web/app/explore/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cookies } from 'next/headers';

import type { BrowseSidebarData } from '~/core/browse/fetch-browse-sidebar-data';
import { fetchBrowseSidebarData } from '~/core/browse/fetch-browse-sidebar-data';
import { resolveMemberSpaceFromWalletSafe } from '~/core/browse/resolve-member-space-from-wallet';
import { WALLET_ADDRESS } from '~/core/cookie';

import { ExplorePage } from '~/partials/explore/explore-page';

function normId(id: string): string {
return id.replace(/-/g, '').toLowerCase();
}

export default async function ExploreRoutePage() {
const wallet = (await cookies()).get(WALLET_ADDRESS)?.value;
let memberSpaceId: string | null = null;
let browse: BrowseSidebarData;
try {
memberSpaceId = wallet ? await resolveMemberSpaceFromWalletSafe(wallet) : null;
browse = await fetchBrowseSidebarData(memberSpaceId);
} catch {
try {
browse = await fetchBrowseSidebarData(null);
} catch {
browse = {
featured: [],
editorOf: [],
memberOf: [],
documentationImage: null,
personalSpaceId: null,
};
}
}

const seen = new Set<string>();
const initialSpaceOptions: { value: string; label: string }[] = [];
for (const row of [...browse.featured, ...browse.editorOf, ...browse.memberOf]) {
const k = normId(row.id);
if (seen.has(k)) continue;
seen.add(k);
initialSpaceOptions.push({ value: row.id, label: row.name });
}

return <ExplorePage initialSpaceOptions={initialSpaceOptions} />;
}
2 changes: 1 addition & 1 deletion apps/web/app/home/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function Component({

return (
<>
<div className="mx-auto max-w-[880px]">
<div className="mx-auto w-full max-w-[880px]">
{header}
<PersonalHomeDashboard
governanceTab={governanceTab}
Expand Down
1 change: 1 addition & 0 deletions apps/web/core/browse/browse-nav-icon-src.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const BROWSE_NAV_ICON = {
root: '/browse-nav/root.svg',
explore: '/browse-nav/explore.svg',
governance: '/browse-nav/governance.svg',
} as const;
30 changes: 30 additions & 0 deletions apps/web/core/browse/resolve-member-space-from-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Effect } from 'effect';

import { getSpaceByAddress } from '~/core/io/queries';
import { fetchProfile } from '~/core/io/subgraph';
import { validateSpaceId } from '~/core/utils/utils';

/**
* Resolves the member (personal) space id for subgraph / membership queries.
* Mirrors browse sidebar resolution so explore and nav stay consistent.
*/
export async function resolveMemberSpaceFromWallet(walletAddress: string): Promise<string | null> {
const profile = await Effect.runPromise(fetchProfile(walletAddress));
if (validateSpaceId(profile.spaceId)) {
return profile.spaceId;
}
const space = await Effect.runPromise(getSpaceByAddress(walletAddress));
return space && validateSpaceId(space.id) ? space.id : null;
}

/**
* Same as {@link resolveMemberSpaceFromWallet} but never throws (subgraph / network errors).
* Used when resolving the wallet must not break pages or API routes that can still serve data.
*/
export async function resolveMemberSpaceFromWalletSafe(walletAddress: string): Promise<string | null> {
try {
return await resolveMemberSpaceFromWallet(walletAddress);
} catch {
return null;
}
}
22 changes: 22 additions & 0 deletions apps/web/core/explore/explore-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Entity types shown on Explore (Geo ontology IDs, hyphenless for GraphQL variables). */
export const EXPLORE_ENTITY_TYPE_IDS = [
'e550fe517e904b2c8fffdf13408f5634',
'972d201ad78045689e01543f67b26bee',
'f3d4461486b74d2583d89709c9d84f65',
'd6f0506def324d8e9de4976b986e78ec',
'96f859efa1ca4b229372c86ad58b694b',
'4d876b81787e41fcab5d075d4da66a3f',
'5e24fb52856c4189a9716af4387b1b89',
'7ed45f2bc48b419e8e4664d5ff680b0d',
'484a18c5030a499cb0f2ef588ff16d50',
] as const;

export const EXPLORE_ENTITY_NAME_PROPERTY_ID = 'a126ca530c8e48d5b88882c734c38935';
export const EXPLORE_ENTITY_DESCRIPTION_PROPERTY_ID = '9b1f76ff9711404c861e59dc3fa7d037';
export const EXPLORE_COVER_PROPERTY_ID = '34f535072e6b42c5a84443981a77cfa2';
export const EXPLORE_AVATAR_PROPERTY_ID = '1155befffad549b7a2e0da4777b8792c';

export const EXPLORE_PAGE_SIZE = 22;

/** Show a "New" badge when the entity was last edited within this window (seconds). */
export const EXPLORE_NEW_POST_MAX_AGE_SEC = 48 * 3600;
117 changes: 117 additions & 0 deletions apps/web/core/explore/explore-entities-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { parse } from 'graphql';

/**
* Like `allEntitiesConnectionDocument`, but scopes `valuesList` / `relationsList` to any of
* the given spaces so multi-space explore feeds still decode cover/avatar/description.
*/
const EXPLORE_ENTITIES_CONNECTION_SOURCE = /* GraphQL */ `
fragment ExplorePropertyFragment on PropertyInfo {
id
name
dataTypeId
dataTypeName
renderableTypeId
renderableTypeName
format
isType
}

query ExploreEntitiesConnection(
$spaceIds: UUIDFilter
$typeIds: UUIDFilter
$limit: Int
$offset: Int
$filter: EntityFilter
$orderBy: [EntitiesOrderBy!]
$spaceIdsForLists: [UUID!]!
) {
entitiesConnection(
first: $limit
offset: $offset
filter: $filter
orderBy: $orderBy
spaceIds: $spaceIds
typeIds: $typeIds
) {
nodes {
id
name
description
spaceIds
updatedAt

types {
id
name
}

valuesList(filter: { spaceId: { in: $spaceIdsForLists } }) {
spaceId
property {
...ExplorePropertyFragment
}
text
integer
float
point
boolean
time
language
unit
datetime
date
decimal
bytes
schedule
}

relationsList(filter: { spaceId: { in: $spaceIdsForLists } }) {
id
spaceId
position
verified
entityId
fromEntity {
id
name
}
toEntity {
id
name
types {
id
name
}
valuesList(filter: { spaceId: { in: $spaceIdsForLists } }) {
spaceId
property {
...ExplorePropertyFragment
}
text
integer
float
point
boolean
time
datetime
date
decimal
bytes
schedule
}
}
toSpaceId
type {
id
name
}
}
}
}
}
`;

export const exploreEntitiesConnectionDocument = parse(
EXPLORE_ENTITIES_CONNECTION_SOURCE
) as TypedDocumentNode<any, any>;
38 changes: 38 additions & 0 deletions apps/web/core/explore/explore-relative-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { formatDistanceToNowStrict } from 'date-fns';

import { EXPLORE_NEW_POST_MAX_AGE_SEC } from './explore-constants';

/**
* GraphQL may return unix seconds as a string, milliseconds, or an ISO datetime string.
*/
export function parseEntityUpdatedAtToUnixSec(raw: string | undefined): number {
if (raw == null || raw === '') return 0;
const s = String(raw).trim();
const n = Number(s);
if (Number.isFinite(n)) {
if (n > 1e12) return Math.floor(n / 1000);
if (n > 1e9) return Math.floor(n);
}
const ms = Date.parse(s);
if (Number.isFinite(ms)) return Math.floor(ms / 1000);
return 0;
}

export function isExploreNewPost(updatedAtSec: number, nowSec = Math.floor(Date.now() / 1000)): boolean {
if (updatedAtSec <= 0) return false;
return nowSec - updatedAtSec <= EXPLORE_NEW_POST_MAX_AGE_SEC;
}

/**
* Compact relative labels for the feed metadata row (e.g. `3m ago`, `2d ago`).
*/
export function formatExploreRelativeTime(updatedAtSec: number): string {
if (updatedAtSec <= 0) return '—';
const date = new Date(updatedAtSec * 1000);
const diffSec = Math.max(0, (Date.now() - updatedAtSec * 1000) / 1000);
if (diffSec < 45) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
if (diffSec < 30 * 86400) return `${Math.floor(diffSec / 86400)}d ago`;
return formatDistanceToNowStrict(date, { addSuffix: true });
}
Loading
Loading