diff --git a/website/src/api/client.ts b/website/src/api/client.ts index 16272bb..ae6d1c2 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -1,11 +1,12 @@ -import NodeCounterService from "./node-counter-service"; import type { - NodeCounterListener, + NodeRpcListener, NodeData, - NodeCounterOptions, - NodeCounterState, -} from "./node-counter-service"; + NodeRpcOptions, + NodeRpcState, +} from "./node-rpc-service"; import env from "@/config"; +import NodeRpcService from "./node-rpc-service"; +import type { EthereumAddressData } from "@/interfaces/EthereumSecurity"; // Types for API responses interface ChainStatsData { @@ -68,111 +69,22 @@ export interface RaidLeaderboardEntrant { last_activity: string; } -interface LeaderboardOptions { - page?: number; - pageSize?: number; - filterByReferralCode?: string; -} - -export interface LeaderboardResponse { - data: LeaderboardEntrant[]; - meta: { - page: number; - page_size: number; - total_items: number; - total_pages: number; - }; -} - -export interface RaidLeaderboardResponse { - data: RaidLeaderboardEntrant[]; - meta: { - page: number; - page_size: number; - total_items: number; - total_pages: number; - }; -} - type ApiResponse = Promise; const createApiClient = () => { - const nodeCounter = new NodeCounterService(); + const nodeRpc = new NodeRpcService(); const methods = { - fetchRaidLeaderboard: ( - options: LeaderboardOptions, - ): ApiResponse => { - const firstRaid = 1; - let url = `${env.TASK_MASTER_URL}/raid-quests/leaderboards/${firstRaid}`; - - let queryParams = []; - if (options.page) queryParams.push(`page=${options.page}`); - if (options.pageSize) queryParams.push(`page_size=${options.pageSize}`); - if (options.filterByReferralCode) - queryParams.push(`referral_code=${options.filterByReferralCode}`); - - if (queryParams.length != 0) url = url + "?" + queryParams.join("&"); - - return fetch(url, { - headers: { - "Content-Type": "application/json", - }, - }); - }, - fetchLeaderboard: ( - options: LeaderboardOptions, - ): ApiResponse => { - let url = `${env.TASK_MASTER_URL}/addresses/leaderboard`; - - let queryParams = []; - if (options.page) queryParams.push(`page=${options.page}`); - if (options.pageSize) queryParams.push(`page_size=${options.pageSize}`); - if (options.filterByReferralCode) - queryParams.push(`referral_code=${options.filterByReferralCode}`); - - if (queryParams.length != 0) url = url + "?" + queryParams.join("&"); - - return fetch(url, { - headers: { - "Content-Type": "application/json", - }, - }); - }, - /** - * Fetch blockchain statistics including transaction and account counts + * Get Ethereum security analysis */ - chainStats: (): ApiResponse> => { - const query = ` - query GetStatus { - allTransactions: eventsConnection( - orderBy: id_ASC, - where: { - OR: { - type_in: [TRANSFER, REVERSIBLE_TRANSFER] - } - } - ) { - totalCount - } - allAccounts: accountsConnection( - orderBy: id_ASC - ) { - totalCount - } - } - `; - - return fetch(env.GRAPHQL_URL, { - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify({ - query, - }), - }); + getEthereumSecurityAnalysis: async ( + addressOrEnsName: string, + ): Promise => { + const data = await fetch( + `${env.TASK_MASTER_URL}/risk-checker/${addressOrEnsName}`, + ); + return (await data.json())?.data as EthereumAddressData | null; }, /** @@ -213,78 +125,53 @@ const createApiClient = () => { }); }, - /** - * Helper method to handle GraphQL responses with proper error checking - */ - async handleGraphQLResponse(response: Response): Promise { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: GraphQLResponse = await response.json(); - - if (data.errors && data.errors.length > 0) { - throw new Error(`GraphQL error: ${data.errors[0].message}`); - } - - if (!data.data) { - throw new Error("No data returned from GraphQL query"); - } - - return data.data; - }, - - /** - * Convenience method to get chain stats with automatic error handling - */ - async getChainStats(): Promise { - const response = await methods.chainStats(); - return methods.handleGraphQLResponse(response); - }, - /** * Node Counter Methods */ - nodeCounter: { + nodeRpc: { /** - * Subscribe to node count updates + * Subscribe to node RPC updates * @param listener Callback function that receives state updates * @returns Unsubscribe function */ - subscribe: (listener: NodeCounterListener) => - nodeCounter.subscribe(listener), + subscribe: (listener: NodeRpcListener) => nodeRpc.subscribe(listener), /** * Get current node counter state */ - getState: () => nodeCounter.getState(), + getState: () => nodeRpc.getState(), /** * Start the WebSocket connection to track nodes */ - connect: () => nodeCounter.connect(), + connect: () => nodeRpc.connect(), /** * Disconnect from the node tracking WebSocket */ - disconnect: () => nodeCounter.disconnect(), + disconnect: () => nodeRpc.disconnect(), /** * Check if currently connected */ - isConnected: () => nodeCounter.isConnected(), + isConnected: () => nodeRpc.isConnected(), /** * Get just the current node count (convenience method) */ - getCount: () => nodeCounter.getState().count, + getCount: () => nodeRpc.getState().count, + + /** + * Get the current block height + */ + getBlockHeight: () => nodeRpc.getState().blockHeight, /** * Get current connection status */ getStatus: () => ({ - status: nodeCounter.getState().status, - message: nodeCounter.getState().statusMessage, + status: nodeRpc.getState().status, + message: nodeRpc.getState().statusMessage, }), }, }; @@ -302,7 +189,7 @@ export type { ContactData, SubscribeData, NodeData, - NodeCounterState, - NodeCounterOptions, - NodeCounterListener, + NodeRpcState, + NodeRpcOptions, + NodeRpcListener, }; diff --git a/website/src/api/node-counter-service.ts b/website/src/api/node-rpc-service.ts similarity index 68% rename from website/src/api/node-counter-service.ts rename to website/src/api/node-rpc-service.ts index 576fb6c..80daec7 100644 --- a/website/src/api/node-counter-service.ts +++ b/website/src/api/node-rpc-service.ts @@ -6,13 +6,14 @@ export interface NodeData { timestamp: number; } -export interface NodeCounterState { +export interface NodeRpcState { count: number | null; + blockHeight: number | null; status: "disconnected" | "connecting" | "connected" | "error"; statusMessage: string; } -export interface NodeCounterOptions { +export interface NodeRpcOptions { wsUrl?: string; maxReconnectAttempts?: number; reconnectDelay?: number; @@ -20,20 +21,20 @@ export interface NodeCounterOptions { nodeTimeout?: number; } -export type NodeCounterListener = (state: NodeCounterState) => void; +export type NodeRpcListener = (state: NodeRpcState) => void; /** * Node Counter Service * Manages WebSocket connection to substrate network and tracks connected nodes */ -class NodeCounterService { +class NodeRpcService { private ws: WebSocket | null = null; - private listeners: Set = new Set(); + private listeners: Set = new Set(); private reconnectAttempts = 0; private heartbeatIntervalId: number | null = null; private isConnecting = false; - private options: Required = { + private options: Required = { wsUrl: "wss://a1-dirac.quantus.cat", maxReconnectAttempts: 5, reconnectDelay: 1000, @@ -41,20 +42,23 @@ class NodeCounterService { nodeTimeout: 300000, // 5 minutes }; - private state: NodeCounterState = { + private state: NodeRpcState = { count: null, + blockHeight: null, status: "disconnected", statusMessage: "Not connected", }; - constructor(options: NodeCounterOptions = {}) { + private blockHeightSubscriptionId: string | null = null; + + constructor(options: NodeRpcOptions = {}) { this.options = { ...this.options, ...options }; } /** * Subscribe to state changes */ - subscribe(listener: NodeCounterListener): () => void { + subscribe(listener: NodeRpcListener): () => void { this.listeners.add(listener); // Immediately call with current state @@ -69,7 +73,7 @@ class NodeCounterService { /** * Get current state snapshot */ - getState(): NodeCounterState { + getState(): NodeRpcState { return { ...this.state, }; @@ -104,9 +108,23 @@ class NodeCounterService { disconnect(): void { this.cleanup(); if (this.ws) { + if ( + this.blockHeightSubscriptionId && + this.ws.readyState === WebSocket.OPEN + ) { + this.ws.send( + JSON.stringify({ + id: 3, + jsonrpc: "2.0", + method: "chain_unsubscribeNewHeads", + params: [this.blockHeightSubscriptionId], + }), + ); + } this.ws.close(); this.ws = null; } + this.blockHeightSubscriptionId = null; this.updateState("disconnected", "Disconnected"); this.isConnecting = false; } @@ -126,15 +144,25 @@ class NodeCounterService { this.reconnectAttempts = 0; this.updateState("connected", "Connected to network"); - // Subscribe to system health (includes peer count) - const healthRequest = { - id: 1, - jsonrpc: "2.0", - method: "system_health", - params: [], - }; - - this.ws?.send(JSON.stringify(healthRequest)); + // Request system health (includes peer count) + this.ws?.send( + JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "system_health", + params: [], + }), + ); + + // Subscribe to new block heads for live block height + this.ws?.send( + JSON.stringify({ + id: 2, + jsonrpc: "2.0", + method: "chain_subscribeNewHeads", + params: [], + }), + ); }; this.ws.onmessage = (event) => { @@ -174,12 +202,26 @@ class NodeCounterService { textData = data; } - const message = await JSON.parse(textData); + const message = JSON.parse(textData); - if (message.id == 1 && message.result?.peers > 0) { - const nodeCount = message.result.peers; - this.state.count = nodeCount; + // system_health response — peer count as validator/node count + if (message.id === 1 && message.result != null) { + this.state.count = message.result.peers ?? this.state.count; + this.notifyListeners(); + } + + // chain_subscribeNewHeads confirmation — store subscription id + if (message.id === 2 && message.result != null) { + this.blockHeightSubscriptionId = message.result; + } + // chain_subscribeNewHeads notification — live block height + if ( + message.method === "chain_newHead" && + message.params?.result?.number != null + ) { + const hexNumber = message.params.result.number as string; + this.state.blockHeight = parseInt(hexNumber, 16); this.notifyListeners(); } } catch (error) { @@ -189,7 +231,7 @@ class NodeCounterService { } private updateState( - status: NodeCounterState["status"], + status: NodeRpcState["status"], statusMessage: string, ): void { this.state.status = status; @@ -239,4 +281,4 @@ class NodeCounterService { } } -export default NodeCounterService; +export default NodeRpcService; diff --git a/website/src/components/features/blog/BlogList.tsx b/website/src/components/features/blog/BlogList.tsx index 82369ed..0ca0e4b 100644 --- a/website/src/components/features/blog/BlogList.tsx +++ b/website/src/components/features/blog/BlogList.tsx @@ -29,6 +29,7 @@ interface Props { noPostsFoundText: string; searchPlaceholder: string; featuredLabel: string; + readLabel: string; tagsMap: Record; } @@ -87,6 +88,7 @@ export const BlogList: React.FC = ({ noPostsFoundText, searchPlaceholder, featuredLabel, + readLabel, tagsMap, }) => { const [query, setQuery] = useState(""); @@ -185,7 +187,7 @@ export const BlogList: React.FC = ({ />
- READ + {readLabel}
@@ -251,7 +253,7 @@ export const BlogList: React.FC = ({ />
- READ + {readLabel}
diff --git a/website/src/components/features/blog/BlogPost.astro b/website/src/components/features/blog/BlogPost.astro index abdb316..ae09098 100644 --- a/website/src/components/features/blog/BlogPost.astro +++ b/website/src/components/features/blog/BlogPost.astro @@ -110,6 +110,7 @@ const articleSchema: WithContext
= { { updatedDate && (

+ {t("blog.post.updated")}{" "} {updatedDate.toLocaleDateString(currentLocale, { year: "numeric", month: "long", diff --git a/website/src/components/features/blog/Card.astro b/website/src/components/features/blog/Card.astro index 70da592..7b4bc78 100644 --- a/website/src/components/features/blog/Card.astro +++ b/website/src/components/features/blog/Card.astro @@ -68,7 +68,7 @@ const formattedDate = pubDate />

- READ + {t("blog.card.read")}
diff --git a/website/src/components/features/community/BlogSection.astro b/website/src/components/features/community/BlogSection.astro index 9ac7225..9fbfd97 100644 --- a/website/src/components/features/community/BlogSection.astro +++ b/website/src/components/features/community/BlogSection.astro @@ -1,8 +1,45 @@ --- -import { createTranslator, getLocaleFromUrl } from "@/utils/i18n"; +import { getCollection } from "astro:content"; +import { + createTranslator, + getLocaleFromUrl, + getLocalizedPath, + DEFAULT_LOCALE, +} from "@/utils/i18n"; const locale = getLocaleFromUrl(Astro.url.pathname); const t = await createTranslator(locale); + +let localePosts = await getCollection("blog", ({ id }) => { + return id.toLowerCase().startsWith(`${locale.toLowerCase()}/`); +}); +if (localePosts.length === 0) { + localePosts = await getCollection("blog", ({ id }) => { + return id.toLowerCase().startsWith(`${DEFAULT_LOCALE.toLowerCase()}/`); + }); +} + +const recentPosts = localePosts + .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) + .slice(0, 3); + +const translations = await import(`../../../i18n/${locale}.json`); +const tagsMap: Record = translations.default.blog?.tags ?? {}; + +function formatDate(date: Date): string { + return date + .toLocaleDateString("en-US", { month: "short", day: "2-digit" }) + .toUpperCase(); +} + +function getTagLabel(tag: string): string { + return (tagsMap[tag] ?? tag.replace(/-/g, " ")).toUpperCase(); +} + +function getBlogUrl(postId: string): string { + const slug = postId.split("/").slice(1).join("/"); + return getLocalizedPath(locale, `/blog/${slug}`); +} ---
@@ -18,71 +55,31 @@ const t = await createTranslator(locale); {t("community.blog.sub")}

- -
-
- - {t("community.blog.posts.post1.date")} - - - {t("community.blog.posts.post1.tag")} - -
- - {t("community.blog.posts.post1.title")} - -
- -
- - -
-
- - {t("community.blog.posts.post2.date")} - - - {t("community.blog.posts.post2.tag")} - -
- - {t("community.blog.posts.post2.title")} - -
- -
- - -
-
- - {t("community.blog.posts.post3.date")} - - - {t("community.blog.posts.post3.tag")} - -
- - {t("community.blog.posts.post3.title")} - -
- -
+ { + recentPosts.map((post, i) => ( + +
+
+ + {formatDate(post.data.pubDate)} + + + {getTagLabel(post.data.tags[0] ?? "")} + +
+ {post.data.title} +
+ +
+ )) + } {t("community.blog.view_all")} diff --git a/website/src/components/features/home/BlogSection.astro b/website/src/components/features/home/BlogSection.astro index a89f9fb..fd8789e 100644 --- a/website/src/components/features/home/BlogSection.astro +++ b/website/src/components/features/home/BlogSection.astro @@ -1,64 +1,84 @@ +--- +import { getCollection } from "astro:content"; +import { + getLocaleFromUrl, + getLocalizedPath, + DEFAULT_LOCALE, + createTranslator, +} from "@/utils/i18n"; + +const locale = getLocaleFromUrl(Astro.url.pathname); +const t = await createTranslator(locale); + +// Fetch posts for the current locale, fall back to DEFAULT_LOCALE if none exist +let localePosts = await getCollection("blog", ({ id }) => { + return id.toLowerCase().startsWith(`${locale.toLowerCase()}/`); +}); +if (localePosts.length === 0) { + localePosts = await getCollection("blog", ({ id }) => { + return id.toLowerCase().startsWith(`${DEFAULT_LOCALE.toLowerCase()}/`); + }); +} + +const recentPosts = localePosts + .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) + .slice(0, 3); + +const translations = await import(`../../../i18n/${locale}.json`); +const tagsMap: Record = translations.default.blog?.tags ?? {}; + +function formatDate(date: Date): string { + return date + .toLocaleDateString("en-US", { month: "short", day: "2-digit" }) + .toUpperCase(); +} + +function getTagLabel(tag: string): string { + return (tagsMap[tag] ?? tag.replace(/-/g, " ")).toUpperCase(); +} + +function getBlogUrl(postId: string): string { + const slug = postId.split("/").slice(1).join("/"); + return getLocalizedPath(locale, `/blog/${slug}`); +} +--- +
diff --git a/website/src/components/features/home/NewsletterSection.astro b/website/src/components/features/home/NewsletterSection.astro index 7cd53b6..dc88a06 100644 --- a/website/src/components/features/home/NewsletterSection.astro +++ b/website/src/components/features/home/NewsletterSection.astro @@ -1,5 +1,6 @@ --- import { createTranslator, getLocaleFromUrl } from "@/utils/i18n"; +import Button from "@/components/ui/Button.astro"; const locale = getLocaleFromUrl(Astro.url.pathname); const t = await createTranslator(locale); @@ -42,9 +43,9 @@ const t = await createTranslator(locale); placeholder={t("home.newsletter.email_placeholder")} aria-label={t("home.newsletter.email_aria")} /> - +