<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Chan Meng on Medium]]></title>
        <description><![CDATA[Stories by Chan Meng on Medium]]></description>
        <link>https://medium.com/@chanmeng666?source=rss-3a918d78aa73------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*J-xjB_0b3scA94tdbc8QOw.jpeg</url>
            <title>Stories by Chan Meng on Medium</title>
            <link>https://medium.com/@chanmeng666?source=rss-3a918d78aa73------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 23 May 2026 06:34:32 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@chanmeng666/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[The Developer’s Guide: Configuring AWS SES for Bulk Email]]></title>
            <link>https://chanmeng666.medium.com/the-developers-guide-configuring-aws-ses-for-bulk-email-4f65df9bd2c9?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/4f65df9bd2c9</guid>
            <category><![CDATA[cloudflare]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[bulk-email-marketing]]></category>
            <category><![CDATA[domains]]></category>
            <category><![CDATA[aws-simple-email-service]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Fri, 16 Jan 2026 12:26:55 GMT</pubDate>
            <atom:updated>2026-01-16T12:26:55.255Z</atom:updated>
            <content:encoded><![CDATA[<p>This guide walks you through setting up a professional email sending infrastructure using Amazon SES (Simple Email Service) and Cloudflare. By the end of this tutorial, you will have a low-cost, high-deliverability system capable of sending newsletters using your own domain.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cpT36X5ogZJ7OrfSBQhz2A.png" /></figure><h3>📋 Prerequisites</h3><ul><li>An AWS Account.</li><li>A domain name managed on Cloudflare (we will use yourdomain.com as the example).</li><li>Basic familiarity with DNS records.</li></ul><h3>Phase 1: Create Identity &amp; DKIM Setup</h3><p>To send emails as newsletter@yourdomain.com, you must prove to AWS that you own the domain.</p><ol><li>Navigate to AWS SES:</li><li>Create Identity:</li></ol><h3>Phase 2: DNS Configuration (Cloudflare)</h3><p>AWS will now provide you with CNAME records. These are the “handshake” that proves ownership and sets up DKIM (email signatures).</p><ol><li>Get the Records:</li><li>Add to Cloudflare:</li><li>Add DMARC (Optional but Recommended):</li></ol><p><em>Verification typically takes a few minutes, but can take up to 72 hours.</em></p><h3>Phase 3: Configure Custom MAIL FROM Domain</h3><p>By default, emails come from amazonses.com. To improve deliverability and look professional (e.g., mail.yourdomain.com), set up a custom MAIL FROM.</p><ol><li>In AWS SES:</li><li>Update Cloudflare DNS:</li><li>Wait for Verification:</li></ol><h3>Phase 4: Get SMTP Credentials</h3><p>To use tools like Listmonk, WordPress, or custom scripts, you need SMTP credentials (username/password), not your standard AWS login.</p><ol><li>Generate Credentials:</li><li>Save Them Immediately:</li></ol><h3>Phase 5: Exiting the “Sandbox” (Production Access)</h3><p>All new SES accounts start in “Sandbox Mode”.</p><ul><li>Restriction: You can only send 200 emails/day, and <em>only</em> to verified email addresses (your own).</li><li>Goal: Move to Production to send to anyone.</li></ul><ol><li>Request Production Access:</li><li>Fill the Form (Best Practices):</li><li>Wait for Review: usually takes 24 hours.</li></ol><h3>Phase 6: Using Your Setup (Example: Listmonk)</h3><p>Now that the infrastructure is ready, you can plug these details into any mailing software.</p><p>Example Configuration:</p><ul><li>SMTP Host: email-smtp.ap-southeast-2.amazonaws.com</li><li>Port: 587</li><li>Username: [Your SMTP Username from Phase 4]</li><li>Password: [Your SMTP Password from Phase 4]</li><li>Encryption: TLS or STARTTLS</li></ul><h3>💰 Cost Analysis (Free Tier)</h3><p>AWS SES is highly cost-effective for developers:</p><ul><li>First 3,000 messages/month: Free (for the first 12 months).</li><li>Thereafter: ~$0.10 per 1,000 emails.</li><li>Data Transfer: Standard EC2 rates apply (negligible for text emails).</li></ul><p>Your professional email infrastructure is now ready!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4f65df9bd2c9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[CopilotKit + LangGraph.js HITL Integration Guide]]></title>
            <link>https://chanmeng666.medium.com/copilotkit-langgraph-js-hitl-integration-guide-964468f1ed5c?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/964468f1ed5c</guid>
            <category><![CDATA[human-in-the-loop]]></category>
            <category><![CDATA[langgraph-tutorial]]></category>
            <category><![CDATA[integration]]></category>
            <category><![CDATA[copilotkit]]></category>
            <category><![CDATA[langgraph]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Thu, 15 Jan 2026 08:56:11 GMT</pubDate>
            <atom:updated>2026-01-15T08:56:11.533Z</atom:updated>
            <content:encoded><![CDATA[<h3>Overview</h3><p>This document summarizes our experience integrating CopilotKit with LangGraph.js for Human-in-the-Loop (HITL) workflows. It covers critical bugs, failed approaches, and the final working solution.</p><p>Tech Stack:</p><ul><li>Frontend: Next.js 16, React 19, CopilotKit 1.x</li><li>AI Runtime: CopilotKit SDK</li><li>AI Agent: LangGraph.js 1.0</li><li>Deployment: Vercel (frontend) + Railway (agent)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WVhwDz2kMwe6MUB8LTkBZQ.png" /></figure><h3>The Problem</h3><p>When implementing HITL approval flows (e.g., user approves AI-generated content before proceeding), we encountered a critical incompatibility between CopilotKit and LangGraph.js.</p><h3>Symptom</h3><pre>ZodError: [<br>  {<br>    &quot;code&quot;: &quot;invalid_type&quot;,<br>    &quot;expected&quot;: &quot;string&quot;,<br>    &quot;received&quot;: &quot;undefined&quot;,<br>    &quot;path&quot;: [&quot;toolCallId&quot;],<br>    &quot;message&quot;: &quot;Required&quot;<br>  }<br>]</pre><h3>Root Cause</h3><p>Field naming mismatch between LangGraph.js and CopilotKit:</p><p>| Framework | Field Name | Format |</p><p>| — — — — — -| — — — — — -| — — — — |</p><p>| LangGraph.js / OpenAI | tool_call_id | snake_case |</p><p>| CopilotKit | toolCallId | camelCase |</p><p>When LangGraph.js creates a ToolMessage, it uses tool_call_id. When CopilotKit tries to parse the message, it expects toolCallId and fails with ZodError.</p><h3>Failed Approaches</h3><h3>Approach 1: Add toolCallId dynamically</h3><pre>const toolMessage = new ToolMessage({<br>  content: result,<br>  name: toolName,<br>  tool_call_id: toolCall.id!,<br>});<br>// Try to add CopilotKit-compatible field<br>(toolMessage as any).toolCallId = toolCall.id!;</pre><p>Result: FAILED</p><p>LangChain’s serialization mechanism ignores dynamically added properties. When the message is serialized for storage/transmission, only declared class fields are included.</p><h3>Approach 2: Use emitMessages: false</h3><pre>const customConfig = copilotkitCustomizeConfig(config, {<br>  emitMessages: false  // Don&#39;t emit ToolMessage to frontend<br>});</pre><p>Result: FAILED</p><p>emitMessages: false only prevents messages from being streamed during execution. The ToolMessage is still stored in LangGraph’s checkpointer and returned when CopilotKit fetches the state later.</p><h3>Approach 3: Route back to chatNode after tool execution</h3><pre>function routeAfterTool(state) {<br>  if (toolCall.name === &quot;generate_outline&quot;) {<br>    return &quot;chat_node&quot;;  // Generate AIMessage to &quot;consume&quot; ToolMessage<br>  }<br>  return END;<br>}</pre><p>Result: FAILED</p><p>The ToolMessage still exists in the conversation history. When CopilotKit reconstructs the history for subsequent requests, it parses all messages including the ToolMessage → ZodError.</p><h3>Approach 4: State-only approach (no messages)</h3><pre>// Don&#39;t add any messages, only emit state<br>if (toolName === &quot;generate_outline&quot;) {<br>  pendingContent = { type: &quot;outline&quot;, content: result };<br>  // NO resultMessages.push() - avoid ToolMessage entirely<br>}<br>await copilotkitEmitState(config, { ...state, pendingContent });<br>return { pendingContent };  // No messages</pre><p>Result: FAILED</p><p>OpenAI’s API requires that every tool_call must have a corresponding ToolMessage response. Without the ToolMessage, subsequent API calls fail:</p><pre>Error: 400 An assistant message with &#39;tool_calls&#39; must be followed by<br>tool messages responding to each &#39;tool_call_id&#39;.</pre><h3>The Working Solution: Dedicated Graph Node</h3><p>The solution is to completely bypass tool calling for HITL operations by using a dedicated graph node instead of a tool.</p><h3>Architecture Comparison</h3><p>Before (Tool-based — BROKEN):</p><pre>User Request → chatNode → LLM calls tool → toolNode → ToolMessage → ZodError</pre><p>After (Node-based — WORKS):</p><pre>User Request → routeFromStart → isOutlineRequest? → outline_node → AIMessage → Success</pre><h3>Implementation</h3><h3>1. Create a detection function</h3><pre>function isOutlineRequest(state: FanficAgentState): boolean {<br>  // Check if we&#39;re in the outline step<br>  const context = extractContextFromReadable(state);<br>  const currentStep = state.wizardSession?.step || context?.step;<br><br>  if (currentStep !== &quot;outline&quot;) {<br>    return false;<br>  }<br><br>  // Check if the message contains outline-related keywords<br>  const lastMessage = state.messages[state.messages.length - 1];<br>  if (!lastMessage || lastMessage._getType() !== &quot;human&quot;) {<br>    return false;<br>  }<br><br>  const content = typeof lastMessage.content === &quot;string&quot;<br>    ? lastMessage.content.toLowerCase()<br>    : &quot;&quot;;<br><br>  const outlinePatterns = [<br>    /write|create|make|generate|outline|story/i,<br>    /romance|adventure|action|drama/i,<br>  ];<br><br>  return outlinePatterns.some(p =&gt; p.test(content));<br>}</pre><h3>2. Create a dedicated node</h3><pre>async function outlineNode(<br>  state: FanficAgentState,<br>  config: RunnableConfig<br>): Promise&lt;Partial&lt;FanficAgentState&gt;&gt; {<br>  // Extract context from CopilotKit readable state<br>  const context = extractContextFromReadable(state);<br>  const sourceName = context?.sourceName || &quot;Unknown&quot;;<br>  const characters = context?.characters || [];<br><br>  // Generate outline directly with LLM (no tool calling)<br>  const model = new ChatOpenAI({ model: &quot;gpt-4o-mini&quot; });<br>  const response = await model.invoke([<br>    new SystemMessage(buildOutlinePrompt(sourceName, characters)),<br>    new HumanMessage(userRequest),<br>  ]);<br><br>  const outlineContent = response.content as string;<br><br>  // Set pendingContent for HITL - frontend will detect this<br>  const pendingContent = {<br>    type: &quot;outline&quot; as const,<br>    content: outlineContent,<br>  };<br><br>  // Emit state for frontend HITL detection<br>  await copilotkitEmitState(config, { ...state, pendingContent });<br><br>  // Return AIMessage (NOT ToolMessage) - CopilotKit compatible<br>  return {<br>    messages: [new AIMessage({<br>      content: &quot;I&#39;ve created your story outline! Please review it above.&quot;,<br>    })],<br>    pendingContent,<br>  };<br>}</pre><h3>3. Update routing from START</h3><pre>function routeFromStart(state: FanficAgentState): string {<br>  // Check for research request first<br>  if (isResearchRequest(state)) {<br>    return &quot;research_node&quot;;<br>  }<br><br>  // Check for outline request<br>  if (isOutlineRequest(state)) {<br>    return &quot;outline_node&quot;;  // Skip chat_node entirely<br>  }<br><br>  // Default to chat node<br>  return &quot;chat_node&quot;;<br>}</pre><h3>4. Add node to graph</h3><pre>const workflow = new StateGraph(FanficAgentStateAnnotation)<br>  .addNode(&quot;chat_node&quot;, chatNode)<br>  .addNode(&quot;tool_node&quot;, toolNode)<br>  .addNode(&quot;research_node&quot;, researchNode)<br>  .addNode(&quot;outline_node&quot;, outlineNode)  // NEW<br>  .addConditionalEdges(START, routeFromStart)<br>  .addConditionalEdges(&quot;chat_node&quot;, routeAfterChat)<br>  .addConditionalEdges(&quot;tool_node&quot;, routeAfterTool)<br>  .addConditionalEdges(&quot;research_node&quot;, routeAfterResearch)<br>  .addConditionalEdges(&quot;outline_node&quot;, routeAfterOutline);  // NEW</pre><h3>5. Remove tool from available tools</h3><pre>// tools/index.ts<br>// Exclude generateOutlineTool - handled by outline_node<br>export const allBackendTools = [<br>  continueStoryTool,<br>  expandSceneTool,<br>  polishProseTool,<br>  ...characterTools,<br>  ...imageTools,<br>  // generateOutlineTool - REMOVED<br>];</pre><h3>6. Frontend: Detect pendingContent with useCoAgentStateRender</h3><pre>// wizard/page.tsx<br>useCoAgentStateRender&lt;{<br>  pendingContent?: { type: string; content: string } | null;<br>}&gt;({<br>  name: &quot;fanfic_agent&quot;,<br>  render: ({ state }) =&gt; {<br>    if (state?.pendingContent?.type === &quot;outline&quot; &amp;&amp; state.pendingContent.content) {<br>      return (<br>        &lt;OutlineApprovalCard<br>          outline={state.pendingContent.content}<br>          onApprove={() =&gt; handleOutlineApproved(state.pendingContent!.content)}<br>          onReject={(feedback) =&gt; console.log(&quot;Rejected:&quot;, feedback)}<br>          onEdit={(editedOutline) =&gt; handleOutlineApproved(editedOutline)}<br>        /&gt;<br>      );<br>    }<br>    return null;<br>  },<br>});</pre><h3>Key Takeaways</h3><h3>1. Avoid ToolMessage for HITL Operations</h3><p>Rule: If an operation requires human approval before proceeding, do NOT implement it as a tool. Use a dedicated graph node instead.</p><p>Why: ToolMessage format is incompatible between LangGraph.js and CopilotKit. This is a known bug (see CopilotKit issue #2897).</p><h3>2. Use State Emission for HITL Detection</h3><p>Instead of relying on CopilotKit actions (useCopilotAction with renderAndWaitForResponse), use:</p><ul><li>copilotkitEmitState() on the backend to emit state with pendingContent</li><li>useCoAgentStateRender() on the frontend to detect and render HITL UI</li></ul><h3>3. Pattern for Dedicated Nodes</h3><p>When creating a dedicated node for HITL operations:</p><pre>async function myHITLNode(state, config) {<br>  // 1. Extract context from CopilotKit readable state<br>  const context = extractContextFromReadable(state);<br><br>  // 2. Perform the operation directly (no tool calling)<br>  const result = await performOperation(context);<br><br>  // 3. Set pendingContent for frontend detection<br>  const pendingContent = { type: &quot;myType&quot;, content: result };<br><br>  // 4. Emit state to frontend<br>  await copilotkitEmitState(config, { ...state, pendingContent });<br><br>  // 5. Return AIMessage (not ToolMessage)<br>  return {<br>    messages: [new AIMessage({ content: &quot;Please review above.&quot; })],<br>    pendingContent,<br>  };<br>}</pre><h3>4. CopilotKit Readable Context Format</h3><p>CopilotKit’s useCopilotReadable stringifies values with JSON.stringify(). On the backend, you must parse them:</p><pre>function extractContextFromReadable(state) {<br>  const copilotState = state.copilotkit;<br><br>  if (copilotState?.context?.length &gt; 0) {<br>    const contextItem = copilotState.context[0];<br><br>    // Value is a JSON string, not an object<br>    if (typeof contextItem.value === &quot;string&quot;) {<br>      try {<br>        return JSON.parse(contextItem.value);<br>      } catch {<br>        return null;<br>      }<br>    }<br>  }<br>  return null;<br>}</pre><h3>5. Graph Routing Pattern</h3><p>For HITL operations, route from START directly to the dedicated node:</p><pre>function routeFromStart(state) {<br>  if (isResearchRequest(state)) return &quot;research_node&quot;;<br>  if (isOutlineRequest(state)) return &quot;outline_node&quot;;<br>  if (isImageRequest(state)) return &quot;image_node&quot;;<br>  return &quot;chat_node&quot;;  // Default for regular conversation<br>}</pre><h3>Summary Table</h3><p>| Operation Type | Implementation | Message Type | HITL Detection |</p><p>| — — — — — — — -| — — — — — — — — | — — — — — — — | — — — — — — — — |</p><p>| Regular chat | chat_node | AIMessage | N/A |</p><p>| Tool execution | tool_node | ToolMessage | ❌ Avoid for HITL |</p><p>| Research | research_node | AIMessage | useCoAgentStateRender |</p><p>| Outline | outline_node | AIMessage | useCoAgentStateRender |</p><p>| Image generation | image_node | AIMessage | useCoAgentStateRender |</p><h3>Related Issues</h3><ul><li>CopilotKit Issue #2897: ToolMessage format mismatch with LangGraph.js</li><li>LangChain serialization: Dynamic properties are ignored</li></ul><h3>Files Modified in This Solution</h3><p>| File | Purpose |</p><p>| — — — | — — — — -|</p><p>| src/agent/agent.ts | Add isOutlineRequest(), outlineNode(), update routing |</p><p>| src/agent/tools/index.ts | Remove generateOutlineTool from exports |</p><p>| src/app/(main)/(protected)/wizard/page.tsx | Add useCoAgentStateRender for HITL |</p><p>| src/components/hitl/OutlineApprovalCard.tsx | HITL approval UI component |</p><h3>Conclusion</h3><p>The CopilotKit + LangGraph.js integration has a critical ToolMessage format incompatibility. The solution is to avoid ToolMessage entirely for HITL operations by using dedicated graph nodes that:</p><ol><li>Route directly from START (bypass chat_node)</li><li>Perform operations with direct LLM calls (no tool calling)</li><li>Emit state with pendingContent for frontend detection</li><li>Return AIMessage (not ToolMessage)</li></ol><p>This pattern has been successfully tested and deployed in production.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=964468f1ed5c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Docusaurus Navbar Styling Guide: Lessons Learned]]></title>
            <link>https://chanmeng666.medium.com/docusaurus-navbar-styling-guide-lessons-learned-fa2faf5b5fbf?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/fa2faf5b5fbf</guid>
            <category><![CDATA[docusaurus]]></category>
            <category><![CDATA[style-guides]]></category>
            <category><![CDATA[navbar]]></category>
            <category><![CDATA[lessons-learned]]></category>
            <category><![CDATA[responsive-navbar]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Thu, 15 Jan 2026 08:30:27 GMT</pubDate>
            <atom:updated>2026-01-15T08:30:27.394Z</atom:updated>
            <content:encoded><![CDATA[<blockquote>A comprehensive guide for Docusaurus developers on navbar and sidebar styling, based on real debugging experience.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p7EjpxjFutNOa-EdsDHaxg.png" /></figure><h3>Background</h3><p>This document records the debugging process and lessons learned from fixing a mobile sidebar navigation issue in a Docusaurus 3.8.1 project. The issue was caused by a single CSS property that broke the entire mobile navigation experience.</p><h3>Project Configuration</h3><ul><li>Docusaurus Version: 3.8.1</li><li>Navbar Style: dark (configured in docusaurus.config.js)</li><li>Features Enabled:</li></ul><h3>The Problem</h3><p>After a homepage redesign commit, the mobile sidebar navigation became broken:</p><ol><li>Symptom: Clicking the hamburger menu button would open the sidebar, but only the header/brand section was visible</li><li>The sidebar content was invisible — users could not see or scroll through the navigation links</li><li>Affected Devices: All mobile devices and screens under 996px width</li></ol><h3>What Users Experienced</h3><pre>┌─────────────────────────┐<br>│  🍔  AI Programming     │  ← Header visible<br>├─────────────────────────┤<br>│                         │<br>│     (Empty space)       │  ← Content invisible!<br>│                         │<br>│                         │<br>└─────────────────────────┘</pre><h3>Root Cause Analysis</h3><h3>The Culprit: backdrop-filter</h3><p>The issue was traced to a single line of CSS added to the .navbar class:</p><pre>/* ❌ This caused the sidebar to break */<br>.navbar {<br>  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);<br>  border-bottom: 1px solid var(--color-light-gray);<br>  background-color: var(--color-background);<br>  backdrop-filter: blur(var(--glass-blur));  /* ← THE PROBLEM */<br>}</pre><h3>Why backdrop-filter Breaks the Sidebar</h3><ol><li>Creates a New Stacking Context: The backdrop-filter property creates a new stacking context, similar to transform, opacity &lt; 1, or filter.</li><li>Affects Child Element Positioning: When applied to the navbar, it interferes with how the mobile sidebar (a child/sibling element) is positioned and rendered.</li><li>Browser-Specific Behavior: The effect can vary across browsers and devices, making it particularly insidious to debug.</li><li>Silent Failure: The sidebar doesn’t show an error — it simply renders invisibly or gets clipped.</li></ol><h3>The Git Bisect That Found It</h3><pre># The problematic commit<br>git show 505695c74c6b5120dad82e6a8306b01448ba3d28<br><br># The last working commit<br>git show def1cace40147421bbdaf3df3bf5a7145f867c30</pre><h3>The Solution</h3><h3>Step 1: Remove backdrop-filter from Navbar</h3><pre>/* ✅ Fixed navbar styling */<br>.navbar {<br>  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);<br>  border-bottom: 1px solid var(--color-light-gray);<br>  background-color: var(--color-background);<br>  /* backdrop-filter removed */<br>}</pre><h3>Step 2: Restore Essential Navigation Styles</h3><p>After fixing the sidebar visibility, we discovered that cleaning up CSS during debugging had removed important styling. The following styles needed to be restored:</p><h3>Brand/Title Styling</h3><pre>.navbar__brand {<br>  font-weight: 700;<br>  font-size: 1.2rem;<br>  color: var(--ifm-color-primary);<br>}<br><br>.navbar__logo {<br>  height: 2rem;<br>  margin-right: 0.5rem;<br>}</pre><h3>Hamburger Menu Icon Styling</h3><pre>/* Light mode */<br>.navbar__toggle {<br>  color: var(--color-text-primary);<br>}<br><br>.navbar__toggle svg path {<br>  fill: var(--color-text-primary);<br>}<br><br>/* Dark mode */<br>[data-theme=&#39;dark&#39;] .navbar__toggle {<br>  color: rgba(255, 255, 255, 0.9);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar__toggle svg path {<br>  fill: rgba(255, 255, 255, 0.9);<br>}</pre><h3>Sidebar Styling</h3><pre>/* Base sidebar styles */<br>.navbar-sidebar {<br>  background-color: var(--color-background);<br>  border-right: 1px solid var(--color-light-gray);<br>}<br><br>.navbar-sidebar__brand {<br>  border-bottom: 1px solid var(--color-light-gray);<br>  padding: 1rem;<br>  background-color: var(--color-background);<br>}<br><br>/* Sidebar links */<br>.navbar-sidebar__items .menu__link,<br>.navbar-sidebar__items .navbar__link {<br>  color: var(--color-text-primary);<br>  font-weight: 500;<br>  padding: 0.5rem 1rem;<br>  border-radius: 4px;<br>}<br><br>/* Dark mode sidebar */<br>[data-theme=&#39;dark&#39;] .navbar-sidebar {<br>  background-color: var(--color-background);<br>  border-right-color: rgba(255, 255, 255, 0.1);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar-sidebar__items .menu__link,<br>[data-theme=&#39;dark&#39;] .navbar-sidebar__items .navbar__link {<br>  color: rgba(255, 255, 255, 0.9);<br>}</pre><h3>Key Lessons Learned</h3><h3>1. ⚠️ Avoid backdrop-filter on Navigation Elements</h3><p>Rule: Never use backdrop-filter on .navbar or parent elements of .navbar-sidebar.</p><pre>/* ❌ DON&#39;T do this */<br>.navbar {<br>  backdrop-filter: blur(10px);<br>}<br><br>/* ✅ If you need blur effect, apply it differently */<br>.navbar::before {<br>  content: &#39;&#39;;<br>  position: absolute;<br>  inset: 0;<br>  backdrop-filter: blur(10px);<br>  z-index: -1;<br>}</pre><h3>2. 🎨 Always Style Both Light and Dark Modes</h3><p>When customizing navigation, always provide styles for both themes:</p><pre>/* Light mode */<br>.navbar__toggle {<br>  color: var(--color-text-primary);<br>}<br><br>/* Dark mode - MUST be explicitly defined */<br>[data-theme=&#39;dark&#39;] .navbar__toggle {<br>  color: rgba(255, 255, 255, 0.9);<br>}</pre><h3>3. 📱 Test Mobile Views After Every CSS Change</h3><p>The mobile sidebar uses different rendering than the desktop navbar. Always test:</p><ul><li>Hamburger menu visibility</li><li>Sidebar opening animation</li><li>Sidebar content scrollability</li><li>Theme switching within sidebar</li></ul><h3>4. 🔍 Use Git Bisect for CSS Bugs</h3><p>CSS bugs can be hard to trace. Use git bisect to find the exact commit:</p><pre>git bisect start<br>git bisect bad HEAD<br>git bisect good &lt;last-known-working-commit&gt;<br># Test each commit until you find the culprit</pre><h3>5. 🎯 Be Careful with Stacking Context Properties</h3><p>These CSS properties create new stacking contexts and can break navigation:</p><h3>6. 📋 Docusaurus Navbar Configuration Matters</h3><p>If you set style: ‘dark’ in docusaurus.config.js:</p><pre>navbar: {<br>  style: &#39;dark&#39;,  // This affects default colors<br>  // ...<br>}</pre><p>You MUST provide explicit color overrides for both themes to ensure visibility.</p><h3>Best Practices for Navbar Styling</h3><h3>DO ✅</h3><ol><li>Use CSS custom properties for colors to maintain theme consistency</li><li>Test on real mobile devices, not just browser dev tools</li><li>Provide complete dark mode styles for all navigation elements</li><li>Use specific selectors to avoid conflicts with Docusaurus defaults</li><li>Document your custom styles for future maintenance</li></ol><h3>DONT ❌</h3><ol><li>Don’t use backdrop-filter on navbar or its ancestors</li><li>Don’t remove Docusaurus default styles without replacement</li><li>Don’t forget mobile-specific styles in media queries</li><li>Don’t assume CSS changes are isolated — always test navigation</li><li>Don’t use !important excessively — it makes debugging harder</li></ol><h3>Complete Working Example</h3><p>Here’s a complete, tested navbar styling setup:</p><pre>/* ===========================================<br>   Navbar Base Styles<br>   =========================================== */<br><br>.navbar {<br>  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);<br>  border-bottom: 1px solid var(--color-light-gray);<br>  background-color: var(--color-background);<br>  /* NO backdrop-filter here! */<br>}<br><br>.navbar__brand {<br>  font-weight: 700;<br>  font-size: 1.2rem;<br>  color: var(--ifm-color-primary);<br>}<br><br>.navbar__link {<br>  color: var(--ifm-color-primary);<br>  font-weight: 600;<br>  padding: 0.5rem 0.75rem;<br>  border-radius: 4px;<br>}<br><br>/* ===========================================<br>   Hamburger Menu &amp; Theme Toggle<br>   =========================================== */<br><br>.navbar__toggle,<br>.clean-btn {<br>  color: var(--color-text-primary);<br>}<br><br>.navbar__toggle svg path,<br>.clean-btn svg path {<br>  fill: var(--color-text-primary);<br>}<br><br>/* ===========================================<br>   Mobile Sidebar<br>   =========================================== */<br><br>.navbar-sidebar {<br>  background-color: var(--color-background);<br>  border-right: 1px solid var(--color-light-gray);<br>}<br><br>.navbar-sidebar__brand {<br>  border-bottom: 1px solid var(--color-light-gray);<br>  background-color: var(--color-background);<br>}<br><br>.navbar-sidebar__items .menu__link,<br>.navbar-sidebar__items .navbar__link {<br>  color: var(--color-text-primary);<br>  font-weight: 500;<br>}<br><br>/* ===========================================<br>   Dark Mode Overrides<br>   =========================================== */<br><br>[data-theme=&#39;dark&#39;] .navbar {<br>  background-color: var(--color-background);<br>  border-bottom-color: rgba(255, 255, 255, 0.1);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar__toggle,<br>[data-theme=&#39;dark&#39;] .clean-btn {<br>  color: rgba(255, 255, 255, 0.9);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar__toggle svg path,<br>[data-theme=&#39;dark&#39;] .clean-btn svg path {<br>  fill: rgba(255, 255, 255, 0.9);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar-sidebar {<br>  background-color: var(--color-background);<br>  border-right-color: rgba(255, 255, 255, 0.1);<br>}<br><br>[data-theme=&#39;dark&#39;] .navbar-sidebar__items .menu__link,<br>[data-theme=&#39;dark&#39;] .navbar-sidebar__items .navbar__link {<br>  color: rgba(255, 255, 255, 0.9);<br>}<br><br>/* ===========================================<br>   Mobile-Specific Styles<br>   =========================================== */<br><br>@media screen and (max-width: 996px) {<br>  .navbar__toggle {<br>    color: var(--color-text-primary) !important;<br>  }<br>  <br>  .navbar__toggle svg path {<br>    fill: var(--color-text-primary) !important;<br>  }<br>  <br>  [data-theme=&#39;dark&#39;] .navbar__toggle {<br>    color: rgba(255, 255, 255, 0.9) !important;<br>  }<br>  <br>  [data-theme=&#39;dark&#39;] .navbar__toggle svg path {<br>    fill: rgba(255, 255, 255, 0.9) !important;<br>  }<br>}</pre><h3>Debugging Checklist</h3><p>When navbar/sidebar issues occur, check these in order:</p><ul><li>Is backdrop-filter used on .navbar or ancestors?</li><li>Are both light and dark mode styles defined?</li><li>Are mobile-specific styles in @media (max-width: 996px) block?</li><li>Is the hamburger icon visible (check SVG fill color)?</li><li>Does the sidebar have proper background-color?</li><li>Are z-index values conflicting?</li><li>Is position: fixed/absolute being affected by stacking context?</li></ul><h3>References</h3><ul><li><a href="https://docusaurus.io/docs/styling-layout">Docusaurus Styling Documentation</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter">MDN: backdrop-filter</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context">MDN: Stacking Context</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fa2faf5b5fbf" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Conversation Implementation Tutorial]]></title>
            <link>https://chanmeng666.medium.com/ai-conversation-implementation-tutorial-01bd036db8ca?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/01bd036db8ca</guid>
            <category><![CDATA[whisper-api]]></category>
            <category><![CDATA[mediarecorder]]></category>
            <category><![CDATA[openai]]></category>
            <category><![CDATA[realtime-api]]></category>
            <category><![CDATA[openai-realtime-api]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Mon, 24 Nov 2025 09:17:27 GMT</pubDate>
            <atom:updated>2025-11-24T09:17:27.479Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>A practical guide for implementing two different AI conversation approaches in ESOL learning platforms</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CVZgkbcWd4OE0vjmisZGwQ.jpeg" /></figure><h3>Overview</h3><p>This project implements two distinct approaches for AI-powered voice conversations:</p><p><strong>Sequential Recording</strong> (/practice/nzcel/conversation)</p><ul><li>Turn-based conversation with manual recording control</li><li>Uses separate API calls for transcription, response generation, and TTS</li><li>Best for structured practice scenarios</li></ul><p><strong>Realtime Streaming</strong> (/speaking)</p><ul><li>Natural, free-flowing conversation with automatic turn detection</li><li>Uses OpenAI Realtime API with WebRTC for bidirectional streaming</li><li>Best for realistic speaking practice</li></ul><h3>Implementation #1: Sequential Voice Recording</h3><h3>Architecture</h3><pre>User Speaks → Record Audio → Stop Recording → Transcribe (Whisper)<br>    ↓<br>Display Transcript → Generate Response (GPT-4) → Convert to Speech (TTS)<br>    ↓<br>Play Audio → Wait for User → Repeat</pre><h3>Core Files</h3><pre>src/<br>├── app/(main)/practice/nzcel/conversation/page.tsx<br>├── components/conversation/realtime-conversation.tsx<br>├── hooks/use-voice-recorder.ts<br>└── app/api/openai/<br>    ├── transcribe/route.ts    # Whisper API<br>    ├── conversation/route.ts  # GPT-4 Chat<br>    └── tts/route.ts          # Text-to-Speech</pre><h3>Key Technologies</h3><ul><li><strong>Audio Recording</strong>: MediaRecorder API (audio/webm)</li><li><strong>Transcription</strong>: OpenAI Whisper (whisper-1)</li><li><strong>AI Response</strong>: GPT-4 Turbo (gpt-4-turbo-preview)</li><li><strong>Text-to-Speech</strong>: OpenAI TTS (tts-1)</li></ul><h3>Implementation Pattern</h3><p><strong>1. Voice Recording Hook</strong> (use-voice-recorder.ts)</p><pre>export function useVoiceRecorder() {<br>  const [state, setState] = useState&lt;VoiceRecordingState&gt;({<br>    isRecording: false,<br>    audioBlob: null,<br>    transcription: null,<br>  });<br>​<br>  const startRecording = async () =&gt; {<br>    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });<br>    const mediaRecorder = new MediaRecorder(stream, { mimeType: &quot;audio/webm&quot; });<br>    <br>    mediaRecorder.ondataavailable = (event) =&gt; {<br>      chunks.push(event.data);<br>    };<br>    <br>    mediaRecorder.onstop = () =&gt; {<br>      const blob = new Blob(chunks, { type: &quot;audio/webm&quot; });<br>      setState(prev =&gt; ({ ...prev, audioBlob: blob }));<br>    };<br>    <br>    mediaRecorder.start();<br>  };<br>​<br>  return { state, startRecording, stopRecording, transcribe, assess };<br>}</pre><p><strong>2. Conversation Flow</strong> (realtime-conversation.tsx)</p><pre>const processUserSpeech = async () =&gt; {<br>  // 1. Transcribe audio<br>  const formData = new FormData();<br>  formData.append(&quot;audio&quot;, new File([audioBlob], &quot;recording.webm&quot;));<br>  <br>  const transcribeResponse = await fetch(&quot;/api/openai/transcribe&quot;, {<br>    method: &quot;POST&quot;,<br>    body: formData,<br>  });<br>  const { text } = await transcribeResponse.json();<br>  <br>  // 2. Generate AI response<br>  const aiResponse = await fetch(&quot;/api/openai/conversation&quot;, {<br>    method: &quot;POST&quot;,<br>    body: JSON.stringify({<br>      messages: [<br>        { role: &quot;system&quot;, content: scenarioContext },<br>        ...conversationHistory,<br>        { role: &quot;user&quot;, content: text }<br>      ],<br>    }),<br>  });<br>  const { response } = await aiResponse.json();<br>  <br>  // 3. Convert to speech<br>  const ttsResponse = await fetch(&quot;/api/openai/tts&quot;, {<br>    method: &quot;POST&quot;,<br>    body: JSON.stringify({ text: response }),<br>  });<br>  const audioBlob = await ttsResponse.blob();<br>  <br>  // 4. Play audio<br>  const audio = new Audio(URL.createObjectURL(audioBlob));<br>  await audio.play();<br>};</pre><p><strong>3. API Routes</strong></p><pre>// Transcription (transcribe/route.ts)<br>export async function POST(request: NextRequest) {<br>  const formData = await request.formData();<br>  const audioFile = formData.get(&quot;audio&quot;) as File;<br>  <br>  const transcription = await openai.audio.transcriptions.create({<br>    file: audioFile,<br>    model: &quot;whisper-1&quot;,<br>    language: &quot;en&quot;,<br>  });<br>  <br>  return NextResponse.json({ text: transcription.text });<br>}<br>​<br>// Conversation (conversation/route.ts)<br>export async function POST(request: NextRequest) {<br>  const { messages } = await request.json();<br>  <br>  const completion = await openai.chat.completions.create({<br>    model: &quot;gpt-4-turbo-preview&quot;,<br>    messages,<br>    temperature: 0.8,<br>    max_tokens: 300,<br>  });<br>  <br>  return NextResponse.json({ response: completion.choices[0].message.content });<br>}</pre><h3>Implementation #2: OpenAI Realtime API</h3><h3>Architecture</h3><pre>User Speaks (continuously) ←→ WebRTC Connection ←→ OpenAI Realtime API<br>         ↓                           ↓                        ↓<br>  VAD Detection              Auto-transcription        AI Response (voice)<br>         ↓                           ↓                        ↓<br>    Turn Detection  →  Save to Database  ←  Stream Audio Back</pre><h3>Core Files</h3><pre>src/<br>├── app/(main)/speaking/page.tsx<br>├── components/speaking/ai-coach.tsx<br>└── app/api/openai/realtime-client-secret/route.ts</pre><h3>Key Technologies</h3><ul><li><strong>SDK</strong>: @openai/agents/realtime</li><li><strong>Transport</strong>: WebRTC with bidirectional streaming</li><li><strong>Voice Activity Detection</strong>: Server-side VAD (threshold: 0.5, silence: 500ms)</li><li><strong>Transcription</strong>: Built-in (gpt-4o-mini-transcribe)</li><li><strong>Response</strong>: Real-time audio streaming</li></ul><h3>Implementation Pattern</h3><p><strong>1. Session Initialization</strong></p><pre>import { RealtimeAgent, RealtimeSession } from &quot;@openai/agents/realtime&quot;;<br>​<br>const startSession = async () =&gt; {<br>  // Get ephemeral client secret<br>  const response = await fetch(&quot;/api/openai/realtime-client-secret&quot;, {<br>    method: &quot;POST&quot;,<br>    body: JSON.stringify({<br>      voice: &quot;verse&quot;,<br>      instructions: ESOL_COACH_INSTRUCTIONS,<br>    }),<br>  });<br>  const { clientSecret } = await response.json();<br>  <br>  // Create agent and session<br>  const agent = new RealtimeAgent({<br>    name: &quot;ESOL Coach&quot;,<br>    instructions: ESOL_COACH_INSTRUCTIONS,<br>  });<br>  <br>  const session = new RealtimeSession(agent, {<br>    transport: &quot;webrtc&quot;,<br>    config: {<br>      audio: {<br>        input: {<br>          transcription: { model: &quot;gpt-4o-mini-transcribe&quot; },<br>        },<br>      },<br>      turn_detection: {<br>        type: &quot;server_vad&quot;,<br>        threshold: 0.5,<br>        silence_duration_ms: 500,<br>      },<br>    },<br>  });<br>  <br>  // Connect to OpenAI<br>  await session.connect({ apiKey: clientSecret });<br>  <br>  // Setup event handlers<br>  setupEventHandlers(session);<br>};</pre><p><strong>2. Event Handlers</strong></p><pre>const setupEventHandlers = (session: RealtimeSession) =&gt; {<br>  // Handle message history updates<br>  session.on(&quot;history_updated&quot;, (history) =&gt; {<br>    const messages = history<br>      .filter(item =&gt; item.type === &quot;message&quot;)<br>      .map(item =&gt; ({<br>        role: item.role === &quot;user&quot; ? &quot;user&quot; : &quot;assistant&quot;,<br>        content: extractTranscript(item.content),<br>        timestamp: new Date(),<br>      }));<br>    <br>    setMessages(messages);<br>    saveToDatabase(messages);<br>  });<br>  <br>  // Handle voice activity detection<br>  session.on(&quot;transport_event&quot;, (event) =&gt; {<br>    if (event.type === &quot;input_audio_buffer.speech_started&quot;) {<br>      setIsSpeaking(true);<br>      startRecordingUserAudio();<br>    } else if (event.type === &quot;input_audio_buffer.speech_stopped&quot;) {<br>      setIsSpeaking(false);<br>      stopRecordingUserAudio();<br>    }<br>  });<br>  <br>  // Handle errors<br>  session.on(&quot;error&quot;, (error) =&gt; {<br>    console.error(&quot;Session error:&quot;, error);<br>    toast.error(&quot;Conversation error occurred&quot;);<br>  });<br>};</pre><p><strong>3. Client Secret Generation</strong></p><pre>// realtime-client-secret/route.ts<br>export async function POST(request: NextRequest) {<br>  const { voice, instructions } = await request.json();<br>  <br>  const sessionConfig = {<br>    session: {<br>      type: &quot;realtime&quot; as const,<br>      model: &quot;gpt-realtime&quot;,<br>      audio: { output: { voice } },<br>      instructions,<br>    },<br>  };<br>  <br>  const response = await fetch(<br>    &quot;https://api.openai.com/v1/realtime/client_secrets&quot;,<br>    {<br>      method: &quot;POST&quot;,<br>      headers: {<br>        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,<br>        &quot;Content-Type&quot;: &quot;application/json&quot;,<br>      },<br>      body: JSON.stringify(sessionConfig),<br>    }<br>  );<br>  <br>  const data = await response.json();<br>  return NextResponse.json({<br>    clientSecret: data.value,<br>    expiresAt: data.expires_at,<br>  });<br>}</pre><p><strong>4. Audio Recording Synchronization</strong></p><pre>// Track user audio for database persistence<br>const mediaRecorder = new MediaRecorder(stream, { mimeType: &#39;audio/webm&#39; });<br>const userAudioChunks = {};<br>const recordingToMessageMap = new Map();<br><br>session.on(&quot;transport_event&quot;, (event) =&gt; {<br>  if (event.type === &quot;input_audio_buffer.speech_started&quot;) {<br>    const recordingIndex = userRecordingCounter++;<br>    const currentUserMessageCount = session.history<br>      .filter(item =&gt; item.type === &quot;message&quot; &amp;&amp; item.role === &quot;user&quot;).length;<br>    <br>    recordingToMessageMap.set(recordingIndex, currentUserMessageCount);<br>    <br>    mediaRecorder.ondataavailable = (e) =&gt; {<br>      if (!userAudioChunks[recordingIndex]) {<br>        userAudioChunks[recordingIndex] = [];<br>      }<br>      userAudioChunks[recordingIndex].push(e.data);<br>    };<br>    <br>    mediaRecorder.start();<br>  } else if (event.type === &quot;input_audio_buffer.speech_stopped&quot;) {<br>    mediaRecorder.stop();<br>  }<br>});</pre><h3>Comparison Matrix</h3><p>| Feature | Sequential Recording | Realtime Streaming |<br>| — — — — -| — — — — — — — — — — -| — — — — — — — — — -|<br>| **Latency** | 3–5 seconds per turn | &lt;1 second |<br>| **User Experience** | Manual button clicks | Natural conversation flow |<br>| **Implementation Complexity** | Medium (3 API calls) | High (WebRTC + events) |<br>| **API Usage** | 3 separate calls per turn | Single persistent connection |<br>| **Cost Model** | Pay per API call | Pay per minute ($0.06/min input, $0.24/min output) |<br>| **Browser Support** | Wide (MediaRecorder API) | Modern browsers (WebRTC) |<br>| **Turn Detection** | Manual (user clicks stop) | Automatic (Voice Activity Detection) |<br>| **Audio Format** | WebM → uploaded | PCM streaming |<br>| **Conversation Style** | Turn-based (structured) | Free-flowing (natural) |<br>| **Setup Time** | ~1 hour | ~3–4 hours |<br>| **Debugging** | Easy (inspect each step) | Moderate (event-driven) |<br>| **Interruption Handling** | Not supported | Built-in (barge-in) |<br>| **Best For** | Practice exercises, assessments | Conversational fluency, immersion |</p><h3>Quick Implementation Guide</h3><h3>Prerequisites</h3><pre>npm install openai @openai/agents sonner</pre><h3>Environment Variables</h3><pre>OPENAI_API_KEY=sk-...</pre><h3>Sequential Recording Setup (30 mins)</h3><p><strong>Create voice recorder hook</strong></p><ul><li>Copy src/hooks/use-voice-recorder.ts</li><li>Handles MediaRecorder lifecycle</li></ul><p><strong>Create API routes</strong></p><ul><li>/api/openai/transcribe - Whisper transcription</li><li>/api/openai/conversation - GPT-4 responses</li><li>/api/openai/tts - Text-to-speech</li></ul><p><strong>Build conversation component</strong></p><ul><li>Use useVoiceRecorder() hook</li><li>Chain: record → transcribe → respond → speak</li><li>Display conversation history</li></ul><p><strong>Test workflow</strong></p><ul><li>Request microphone permission</li><li>Record → transcribe → respond</li><li>Verify audio playback</li></ul><h3>Realtime Streaming Setup (2 hours)</h3><p><strong>Install Realtime SDK</strong></p><pre>npm install @openai/agents</pre><p><strong>Create client secret endpoint</strong></p><ul><li>Copy src/app/api/openai/realtime-client-secret/route.ts</li><li>Generates ephemeral tokens</li></ul><p><strong>Build coach component</strong></p><ul><li>Initialize RealtimeAgent and RealtimeSession</li><li>Setup WebRTC transport</li><li>Configure VAD parameters</li></ul><p><strong>Implement event handlers</strong></p><ul><li>history_updated - Update conversation transcript</li><li>transport_event - Handle speech detection</li><li>error - Handle connection issues</li></ul><p><strong>Add audio recording</strong> (optional for persistence)</p><ul><li>Setup parallel MediaRecorder</li><li>Sync recordings with message indices</li><li>Upload to storage</li></ul><p><strong>Test real-time flow</strong></p><ul><li>Verify WebRTC connection</li><li>Test voice activity detection</li><li>Confirm natural turn-taking</li></ul><h3>When to Use Each Approach</h3><h3>Choose Sequential Recording When:</h3><p>✅ <strong>Budget is a primary concern</strong></p><ul><li>Pay only for API calls used</li><li>Predictable costs per conversation</li></ul><p>✅ <strong>You need full control</strong></p><ul><li>Explicit start/stop recording</li><li>Validate each step separately</li><li>Custom processing between steps</li></ul><p>✅ <strong>Implementing structured practice</strong></p><ul><li>Question-answer format</li><li>Assessment-focused exercises</li><li>Clear turn boundaries</li></ul><p>✅ <strong>Supporting older browsers</strong></p><ul><li>Requires only MediaRecorder API</li><li>Broader compatibility</li></ul><p>✅ <strong>Debugging is critical</strong></p><ul><li>Easy to inspect each pipeline stage</li><li>Clear error boundaries</li></ul><h3>Choose Realtime Streaming When:</h3><p>✅ <strong>User experience is paramount</strong></p><ul><li>Natural, free-flowing conversations</li><li>Minimal latency (&lt;1 second)</li><li>Interrupt and respond (barge-in)</li></ul><p>✅ <strong>Simulating real conversations</strong></p><ul><li>Speaking fluency practice</li><li>Interview preparation</li><li>Casual dialogue</li></ul><p>✅ <strong>Users have modern browsers</strong></p><ul><li>WebRTC support required</li><li>Chrome, Edge, Safari (recent versions)</li></ul><p>✅ <strong>Volume justifies setup time</strong></p><ul><li>Higher upfront complexity</li><li>Better UX for frequent use</li></ul><p>✅ <strong>You want built-in features</strong></p><ul><li>Automatic transcription</li><li>Voice activity detection</li><li>Turn management included</li></ul><h3>Common Pitfalls &amp; Solutions</h3><h3>Sequential Recording</h3><p><strong>Issue</strong>: Audio blob is null after stopping recording</p><pre>// ❌ Wrong: Process immediately<br>stopRecording();<br>processAudio(state.audioBlob); // blob is still null!<br><br>// ✅ Correct: Use effect to wait for blob<br>useEffect(() =&gt; {<br>  if (!state.isRecording &amp;&amp; state.audioBlob) {<br>    processAudio(state.audioBlob);<br>  }<br>}, [state.audioBlob, state.isRecording]);</pre><p><strong>Issue</strong>: Microphone stays active after component unmounts</p><pre>// ✅ Always cleanup<br>useEffect(() =&gt; {<br>  return () =&gt; {<br>    if (streamRef.current) {<br>      streamRef.current.getTracks().forEach(track =&gt; track.stop());<br>    }<br>  };<br>}, []);</pre><h3>Realtime Streaming</h3><p><strong>Issue</strong>: Message and audio recording out of sync</p><pre>// ✅ Map recording index to expected message index<br>const recordingToMessageMap = new Map();<br><br>session.on(&quot;transport_event&quot;, (event) =&gt; {<br>  if (event.type === &quot;input_audio_buffer.speech_started&quot;) {<br>    const recordingIndex = userRecordingCounter++;<br>    const expectedMessageIndex = getCurrentUserMessageCount();<br>    recordingToMessageMap.set(recordingIndex, expectedMessageIndex);<br>  }<br>});</pre><p><strong>Issue</strong>: Session not cleaning up properly</p><pre>// ✅ Comprehensive cleanup<br>const stopSession = async () =&gt; {<br>  // Stop all recordings<br>  if (mediaRecorder?.state === &quot;recording&quot;) {<br>    mediaRecorder.stop();<br>  }<br>  <br>  // Stop media streams<br>  mediaRecorder?.stream?.getTracks().forEach(track =&gt; track.stop());<br>  <br>  // Close session<br>  session?.close();<br>  <br>  // Clear refs<br>  sessionRef.current = null;<br>  mediaRecorderRef.current = null;<br>};</pre><h3>Best Practices</h3><h3>Security</h3><pre>// ✅ Never expose API keys in client code<br>// Use API routes to proxy requests<br>const response = await fetch(&quot;/api/openai/realtime-client-secret&quot;, {<br>  method: &quot;POST&quot;<br>});<br><br>// ❌ Never do this<br>const session = new RealtimeSession(agent, {<br>  apiKey: process.env.OPENAI_API_KEY // Exposed to client!<br>});</pre><h3>Performance</h3><pre>// ✅ Cache TTS audio for repeated questions<br>const getQuestionAudio = async (questionId: string, text: string) =&gt; {<br>  // Check cache first<br>  const cached = await db.query.questionAudioCache.findFirst({<br>    where: eq(schema.questionAudioCache.questionId, questionId)<br>  });<br>  <br>  if (cached) return cached.audioUrl; // 90% cost reduction<br>  <br>  // Generate and cache<br>  const audio = await generateTTS(text);<br>  await cacheAudio(questionId, audio);<br>  return audio;<br>};</pre><h3>User Experience</h3><pre>// ✅ Provide clear feedback at each stage<br>toast.info(&quot;Transcribing your response...&quot;);<br>toast.info(&quot;AI is thinking...&quot;);<br>toast.success(&quot;Ready for your next response!&quot;);<br><br>// ✅ Show recording indicator<br>{state.isRecording &amp;&amp; (<br>  &lt;div className=&quot;flex items-center gap-2&quot;&gt;<br>    &lt;div className=&quot;h-3 w-3 rounded-full bg-red-500 animate-pulse&quot; /&gt;<br>    &lt;span&gt;Recording... ({state.recordingDuration}s)&lt;/span&gt;<br>  &lt;/div&gt;<br>)}</pre><h3>Error Handling</h3><pre>// ✅ Graceful degradation<br>try {<br>  const transcription = await transcribe();<br>  const response = await generateResponse(transcription);<br>  await speakResponse(response);<br>} catch (error) {<br>  if (error.message.includes(&quot;microphone&quot;)) {<br>    toast.error(&quot;Microphone access denied. Please check permissions.&quot;);<br>  } else if (error.message.includes(&quot;API&quot;)) {<br>    toast.error(&quot;Service temporarily unavailable. Please try again.&quot;);<br>  } else {<br>    toast.error(&quot;An error occurred. Please refresh the page.&quot;);<br>  }<br>}</pre><h3>Advanced Topics</h3><h3>Custom AI Instructions</h3><pre>const INSTRUCTIONS = `You are an ESOL speaking coach.<br><br>- Adapt to CEFR level: ${userLevel}<br>- Focus areas: ${focusSkills.join(&quot;, &quot;)}<br>- Provide feedback on: fluency, accuracy, coherence<br>- Keep responses under 20 seconds<br>- Encourage learner after each turn`;</pre><h3>Audio Persistence Strategy</h3><pre>// Sequential: Simple - save after each turn<br>await saveUserRecording({<br>  sessionId,<br>  audioBlob,<br>  transcription,<br>  timestamp: new Date(),<br>});<br><br>// Realtime: Complex - sync with message history<br>// Map recording index → message index → upload when message saved</pre><h3>Session Analytics</h3><pre>interface SessionMetrics {<br>  duration: number;           // Total session time<br>  turnCount: number;          // Number of exchanges<br>  userSpeakingTime: number;   // Active speaking time<br>  avgResponseLatency: number; // Sequential only<br>  completionRate: number;     // % of target turns reached<br>}</pre><h3>Conclusion</h3><p>Both approaches have their place in ESOL learning platforms:</p><p><strong>Sequential Recording</strong>: Reliable, cost-effective, and easier to implement. Perfect for structured practice, assessments, and budget-conscious applications.</p><p><strong>Realtime Streaming</strong>: Premium experience with natural conversation flow. Ideal for advanced learners, fluency practice, and applications where UX justifies the complexity.</p><p>Consider implementing both: use Sequential Recording for structured exercises and Realtime Streaming for conversational practice. This hybrid approach provides flexibility for different learning contexts and user needs.</p><h3>Resources</h3><h3>Project Files</h3><p><strong>Sequential Implementation</strong>:</p><ul><li>src/components/conversation/realtime-conversation.tsx - Main component</li><li>src/hooks/use-voice-recorder.ts - Recording logic</li><li>src/app/api/openai/transcribe/route.ts - Whisper API</li><li>src/app/api/openai/conversation/route.ts - GPT-4 API</li><li>src/app/api/openai/tts/route.ts - Text-to-speech API</li></ul><p><strong>Realtime Implementation</strong>:</p><ul><li>src/components/speaking/ai-coach.tsx - Main component</li><li>src/app/api/openai/realtime-client-secret/route.ts - Authentication</li></ul><h3>External Documentation</h3><ul><li><a href="https://platform.openai.com/docs/guides/realtime">OpenAI Realtime API Guide</a></li><li><a href="https://platform.openai.com/docs/guides/speech-to-text">OpenAI Whisper API</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder">MediaRecorder API (MDN)</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">WebRTC API (MDN)</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=01bd036db8ca" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fixing Google Fonts in Next.js 13+: A Pixel-Perfect Guide]]></title>
            <link>https://chanmeng666.medium.com/fixing-google-fonts-in-next-js-13-a-pixel-perfect-guide-e626efb8e248?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/e626efb8e248</guid>
            <category><![CDATA[google-fonts]]></category>
            <category><![CDATA[css]]></category>
            <category><![CDATA[nextjs]]></category>
            <category><![CDATA[pixel-game]]></category>
            <category><![CDATA[pixel-font]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Mon, 24 Nov 2025 06:52:37 GMT</pubDate>
            <atom:updated>2025-11-24T06:52:37.087Z</atom:updated>
            <content:encoded><![CDATA[<p>Recently, while building a retro gaming website with Next.js, I ran into a frustrating issue: my pixel fonts weren’t loading. Here’s what I learned about the right way to handle Google Fonts in modern Next.js applications.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C6BxdKNfbJWb8nLFeebyKw.png" /></figure><h3>The Problem</h3><p>I was using CSS @import to load Google Fonts:</p><pre>@import url(&#39;https://fonts.googleapis.com/css2?family=Press+Start+2P&amp;display=swap&#39;);</pre><p>Result? All fonts failed to load. The browser’s document.fonts showed nothing. My pixel-art aesthetic was ruined.</p><h3>Why CSS @import Fails in Next.js</h3><p>Next.js 13+ has a built-in font optimization system that doesn’t play well with traditional CSS imports. Using @import:</p><ul><li>Bypasses Next.js optimizations</li><li>Creates unnecessary external requests</li><li>Can cause Flash of Unstyled Text (FOUT)</li><li>May not load reliably during build</li></ul><h3>The Solution: Use next/font/google</h3><p>Here’s the proper approach in 3 simple steps:</p><h3>Step 1: Import Fonts in app/layout.tsx</h3><pre>import { Press_Start_2P, VT323, Pixelify_Sans } from &quot;next/font/google&quot;;<br><br>const pressStart2P = Press_Start_2P({<br>  weight: &quot;400&quot;,<br>  subsets: [&quot;latin&quot;],<br>  variable: &quot;--font-press-start&quot;,<br>  display: &quot;swap&quot;,<br>});<br><br>const vt323 = VT323({<br>  weight: &quot;400&quot;,<br>  subsets: [&quot;latin&quot;],<br>  variable: &quot;--font-vt323&quot;,<br>  display: &quot;swap&quot;,<br>}); </pre><h3>Step 2: Apply Font Variables to HTML</h3><pre>export default function RootLayout({ children }) {<br>  return (<br>    &lt;html className={`${pressStart2P.variable} ${vt323.variable}`}&gt;<br>      &lt;body className={pressStart2P.className}&gt;<br>        {children}<br>      &lt;/body&gt;<br>    &lt;/html&gt;<br>  );<br>}</pre><h3>Step 3: Update CSS to Use Variables</h3><p>Remove the @import statements and use CSS variables instead:</p><pre>body {<br>  font-family: var(--font-press-start), &#39;Courier New&#39;, monospace;<br>}<br><br>.font-pixel-display {<br>  font-family: var(--font-press-start), &#39;Courier New&#39;, monospace;<br>} </pre><p>Bonus: Update your Tailwind config for utility classes:</p><pre>fontFamily: {<br>  &#39;pixel-display&#39;: [&#39;var(--font-press-start)&#39;, &#39;monospace&#39;],<br>  &#39;pixel-content&#39;: [&#39;var(--font-vt323)&#39;, &#39;monospace&#39;],<br>}</pre><h3>Why This Works Better</h3><p>✅ Self-hosted: Fonts are served from your domain, not Google’s ✅ Zero layout shift: No flash of unstyled text ✅ Better performance: Automatic optimization and preloading ✅ Privacy-friendly: No external tracking ✅ Type-safe: TypeScript support out of the box</p><h3>Testing Your Fix</h3><p>After implementing these changes:</p><ol><li>Restart your dev server (important!)</li><li>Hard refresh your browser (Ctrl+Shift+R / Cmd+Shift+R)</li><li>Check DevTools: Inspect any text element and verify the computed font-family shows your custom font</li><li>Look for font files in the Network tab with status 200</li></ol><h3>Troubleshooting</h3><p>If fonts still don’t appear:</p><pre># Clear Next.js cache<br>rm -rf .next<br>npm run dev</pre><p>Then hard refresh your browser again.</p><h3>Key Takeaway</h3><p>Don’t use CSS @import for fonts in Next.js 13+. Always use next/font/google or next/font/local for optimal performance and reliability.</p><p>This approach transforms font loading from a potential bottleneck into a strength, giving you:</p><ul><li>Faster page loads</li><li>Better user experience</li><li>Zero configuration optimization</li></ul><p>Resources:</p><ul><li><a href="https://nextjs.org/docs/app/building-your-application/optimizing/fonts">Next.js Font Optimization Docs</a></li><li><a href="https://nextjs.org/docs/app/api-reference/components/font">next/font API Reference</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e626efb8e248" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Chatbot Integration Guide for Website]]></title>
            <link>https://chanmeng666.medium.com/ai-chatbot-integration-guide-for-website-931a871df9b0?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/931a871df9b0</guid>
            <category><![CDATA[ai-chatbot]]></category>
            <category><![CDATA[chatbots]]></category>
            <category><![CDATA[gemini]]></category>
            <category><![CDATA[google-ai-studio]]></category>
            <category><![CDATA[google-gemini]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Fri, 01 Aug 2025 05:08:58 GMT</pubDate>
            <atom:updated>2025-08-01T05:08:58.198Z</atom:updated>
            <content:encoded><![CDATA[<h3>Overview</h3><p>This document provides a comprehensive guide on how the AI chatbot was integrated into the She Sharp website. It covers the technical implementation, challenges encountered, and solutions applied during the development process.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eD8uOTS0ZqkMekfl35nDiQ.png" /></figure><h3>Table of Contents</h3><ol><li><a href="#project-context">Project Context</a></li><li><a href="#technology-stack">Technology Stack</a></li><li><a href="#implementation-steps">Implementation Steps</a></li><li><a href="#key-features">Key Features</a></li><li><a href="#challenges-and-solutions">Challenges and Solutions</a></li><li><a href="#streaming-response-implementation">Streaming Response Implementation</a></li><li><a href="#environment-configuration">Environment Configuration</a></li><li><a href="#deployment-considerations">Deployment Considerations</a></li><li><a href="#best-practices-and-lessons-learned">Best Practices and Lessons Learned</a></li></ol><h3>Project Context</h3><p>She Sharp is a non-profit organization dedicated to bridging the gender gap in STEM fields. The AI chatbot was integrated to provide instant assistance to website visitors, answering questions about:</p><ul><li>Organization programs and services</li><li>Mentorship opportunities</li><li>Events and workshops</li><li>How to get involved</li></ul><h3>Technology Stack</h3><h3>Core Technologies</h3><ul><li><strong>Framework</strong>: Next.js 15.4.0 with App Router</li><li><strong>Language</strong>: TypeScript</li><li><strong>AI SDK</strong>: Vercel AI SDK v4.3.19</li><li><strong>AI Provider</strong>: Google Gemini Pro (via @ai-sdk/google v1.2.22)</li><li><strong>Additional SDK</strong>: @google/generative-ai v0.24.1</li><li><strong>UI Animation</strong>: Framer Motion v12.23.9</li><li><strong>UI Components</strong>: shadcn/ui with Radix UI</li><li><strong>Styling</strong>: Tailwind CSS v4</li></ul><h3>Key Dependencies</h3><pre>{<br>  &quot;@ai-sdk/google&quot;: &quot;^1.2.22&quot;,<br>  &quot;@google/generative-ai&quot;: &quot;^0.24.1&quot;,<br>  &quot;ai&quot;: &quot;^4.3.19&quot;,<br>  &quot;framer-motion&quot;: &quot;^12.23.9&quot;<br>}</pre><h3>Implementation Steps</h3><h3>1. Initial Setup</h3><p>First, install the required dependencies:</p><pre>pnpm add ai @ai-sdk/google framer-motion</pre><h3>2. Component Structure</h3><p>The chatbot implementation consists of several modular components:</p><pre>components/chatbot/<br>├── chatbot.tsx              # Main chatbot component<br>├── chat-message.tsx         # Individual message component<br>├── typing-indicator.tsx     # Animated typing indicator<br>├── quick-actions.tsx        # Preset questions panel<br>├── preset-questions.ts      # Preset Q&amp;A data<br>├── types.ts                # TypeScript interfaces<br>└── chatbot-provider.tsx    # Client-side wrapper</pre><h3>3. API Route Implementation</h3><p>Create the chat API endpoint at app/api/chat/route.ts:</p><pre>import { google } from &#39;@ai-sdk/google&#39;;<br>import { convertToCoreMessages, streamText } from &#39;ai&#39;;<br>​<br>export async function POST(req: Request) {<br>  const { messages } = await req.json();<br>  <br>  // Clean messages and filter system messages<br>  const cleanedMessages = messages<br>    .filter((msg: any) =&gt; msg.id !== &#39;system&#39;)<br>    .map((msg: any) =&gt; ({<br>      role: msg.role,<br>      content: msg.content<br>    }));<br>  <br>  const model = google(&#39;gemini-1.5-flash&#39;);<br>  <br>  const result = await streamText({<br>    model,<br>    system: &#39;You are a helpful assistant for She Sharp...&#39;,<br>    messages: convertToCoreMessages(cleanedMessages),<br>    temperature: 0.7,<br>    maxTokens: 500,<br>  });<br>  <br>  return result.toDataStreamResponse();<br>}</pre><h3>4. Integration into Layout</h3><p>Add the chatbot to the root layout (app/layout.tsx):</p><pre>import { ChatbotProvider } from &#39;@/components/chatbot/chatbot-provider&#39;;<br>​<br>export default function RootLayout({ children }) {<br>  return (<br>    &lt;html&gt;<br>      &lt;body&gt;<br>        {children}<br>        &lt;ChatbotProvider /&gt;<br>      &lt;/body&gt;<br>    &lt;/html&gt;<br>  );<br>}</pre><h3>Key Features</h3><h3>1. Streaming Responses</h3><ul><li>Real-time AI responses with character-by-character display</li><li>Visual typing indicator during response generation</li></ul><h3>2. Preset Questions System</h3><ul><li>8 pre-configured common questions</li><li>Categorized by topic (About, Events, Mentorship, Support, General)</li><li>Instant responses without API calls</li></ul><h3>3. Chat History Persistence</h3><ul><li>LocalStorage implementation</li><li>Stores up to 50 messages</li><li>Survives page refreshes</li></ul><h3>4. User Experience Features</h3><ul><li>Keyboard shortcuts (⌘K to open/close)</li><li>Auto-focus on input field</li><li>Smooth animations with Framer Motion</li><li>Responsive design for mobile and desktop</li><li>Clear chat history functionality</li></ul><h3>Challenges and Solutions</h3><h3>Challenge 1: TypeScript Type Errors</h3><p><strong>Error</strong>:</p><pre>Type error: Type &#39;&quot;user&quot; | &quot;data&quot; | &quot;system&quot; | &quot;assistant&quot;&#39; is not assignable to type &#39;&quot;user&quot; | &quot;assistant&quot;&#39;</pre><p><strong>Solution</strong>: Add type assertion when passing role prop:</p><pre>role={message.role as &#39;user&#39; | &#39;assistant&#39;}</pre><h3>Challenge 2: Missing Tailwind CSS Classes</h3><p><strong>Error</strong>:</p><pre>[Error: Cannot apply unknown utility class: md:text-3xl]</pre><p><strong>Solution</strong>: Define responsive text utilities in globals.css:</p><pre>@media (min-width: 768px) {<br>  .md\:text-3xl { font-size: var(--font-size-3xl); }<br>  .md\:text-4xl { font-size: var(--font-size-4xl); }<br>  /* ... other sizes */<br>}</pre><h3>Challenge 3: Environment Variable Issues</h3><p><strong>Problem</strong>: API key not being recognized by the SDK</p><p><strong>Solution</strong>:</p><ol><li>Ensure correct environment variable name: GOOGLE_GENERATIVE_AI_API_KEY</li><li>Add to .env.local (not .env)</li><li>Restart the development server after changes</li></ol><h3>Streaming Response Implementation</h3><h3>Initial Problem</h3><p>The initial implementation using GoogleGenerativeAIStream and StreamingTextResponse failed with import errors:</p><pre>Export GoogleGenerativeAIStream doesn&#39;t exist in target module<br>Export StreamingTextResponse doesn&#39;t exist in target module</pre><h3>Investigation Process</h3><ol><li><strong>First Attempt</strong>: Direct Google Generative AI SDK integration</li></ol><ul><li>Created custom streaming implementation</li><li>Encountered format compatibility issues with Vercel AI SDK</li></ul><p><strong>2. Second Attempt</strong>: Simple test API</p><ul><li>Created /api/chat-simple endpoint to verify frontend functionality</li><li>Confirmed frontend could handle streaming responses correctly</li></ul><p><strong>3. Final Solution</strong>: Correct Vercel AI SDK Usage</p><p>The key was using the correct imports and methods from Vercel AI SDK v4:</p><pre>import { google } from &#39;@ai-sdk/google&#39;;<br>import { convertToCoreMessages, streamText } from &#39;ai&#39;;<br>​<br>// Create model instance<br>const model = google(&#39;gemini-1.5-flash&#39;);<br>​<br>// Use streamText with proper configuration<br>const result = await streamText({<br>  model,<br>  system: systemPrompt,<br>  messages: convertToCoreMessages(cleanedMessages),<br>  temperature: 0.7,<br>  maxTokens: 500,<br>});<br>​<br>// Return the stream response<br>return result.toDataStreamResponse();</pre><h3>Critical Fixes for Streaming</h3><ol><li><strong>Import Corrections</strong>: Used streamText instead of non-existent GoogleGenerativeAIStream</li><li><strong>Message Format</strong>: Used convertToCoreMessages() to ensure proper message format</li><li><strong>Response Method</strong>: Used result.toDataStreamResponse() for proper streaming format</li><li><strong>Error Scope</strong>: Fixed variable scope issue where result was defined inside try block but used outside</li></ol><h3>Environment Configuration</h3><h3>Required Environment Variables</h3><p>Add to .env.local:</p><pre>GOOGLE_GENERATIVE_AI_API_KEY=your_api_key_here</pre><h3>Important Notes:</h3><ul><li>Use .env.local for Next.js projects (not .env)</li><li>The SDK automatically reads GOOGLE_GENERATIVE_AI_API_KEY</li><li>Never commit API keys to version control</li></ul><h3>Deployment Considerations</h3><h3>Vercel Deployment</h3><ol><li><strong>Environment Variables</strong>: Add GOOGLE_GENERATIVE_AI_API_KEY in Vercel project settings</li><li><strong>Build Errors</strong>: Ensure all TypeScript errors are resolved</li><li><strong>Tailwind CSS</strong>: Verify all utility classes are properly defined</li></ol><h3>Performance Optimization</h3><ol><li><strong>Dynamic Import</strong>: Chatbot is lazy-loaded to improve initial page load</li></ol><pre>const Chatbot = dynamic(() =&gt; import(&#39;./chatbot&#39;).then(mod =&gt; ({ default: mod.Chatbot })), {<br>  ssr: false,<br>  loading: () =&gt; null<br>});</pre><p><strong>2. Message Limits</strong>: Store only last 50 messages to prevent localStorage bloat</p><h3>Best Practices and Lessons Learned</h3><h3>1. API Integration</h3><ul><li>Always verify API key configuration before debugging other issues</li><li>Use proper error handling and logging for debugging</li><li>Test with simple implementations first to isolate problems</li></ul><h3>2. Streaming Responses</h3><ul><li>Understand the specific requirements of your AI SDK version</li><li>Verify import statements match the actual SDK exports</li><li>Use browser developer tools to monitor network requests</li></ul><h3>3. Type Safety</h3><ul><li>Define clear TypeScript interfaces for all data structures</li><li>Use type assertions sparingly and only when necessary</li><li>Leverage TypeScript’s type inference where possible</li></ul><h3>4. User Experience</h3><ul><li>Provide visual feedback during AI response generation</li><li>Include preset questions for common queries</li><li>Implement keyboard shortcuts for power users</li><li>Ensure responsive design works on all devices</li></ul><h3>5. Error Handling</h3><ul><li>Display user-friendly error messages</li><li>Log detailed errors for debugging</li><li>Implement retry mechanisms for transient failures</li></ul><h3>Conclusion</h3><p>The AI chatbot integration demonstrates the power of modern web technologies in creating interactive user experiences. By leveraging Vercel AI SDK with Google’s Gemini model, we created a responsive, intelligent assistant that enhances the She Sharp website’s ability to serve its community.</p><p>Key takeaways:</p><ul><li>Proper SDK usage and understanding documentation is crucial</li><li>Iterative debugging and testing helps identify root causes</li><li>Modular component design improves maintainability</li><li>User experience should be the primary focus</li></ul><p>For questions or improvements, refer to the codebase or contact the development team.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=931a871df9b0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a PDF Download Form with Notion Integration in Docusaurus: A Complete Guide]]></title>
            <link>https://chanmeng666.medium.com/building-a-pdf-download-form-with-notion-integration-in-docusaurus-a-complete-guide-034e63e7a193?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/034e63e7a193</guid>
            <category><![CDATA[api-design]]></category>
            <category><![CDATA[notion]]></category>
            <category><![CDATA[api]]></category>
            <category><![CDATA[docusaurus]]></category>
            <category><![CDATA[notion-integration]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Wed, 28 May 2025 07:36:35 GMT</pubDate>
            <atom:updated>2025-05-28T07:36:35.290Z</atom:updated>
            <content:encoded><![CDATA[<h3>Introduction</h3><p>Implementing a “Download PDF Report” feature with user data collection in a Docusaurus website presents unique challenges, especially when integrating with external services like Notion. This article documents our journey building this functionality for the FemTech Weekend website, covering the obstacles we faced and the solutions we implemented. By the end, you’ll understand how to create a PDF download component that collects user information and stores it in a Notion database, working seamlessly in both local development and Vercel deployment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CqIopKtO_YgjXerhCEOxLw.png" /></figure><h3>Project Overview</h3><p>Our goal was to create a component that would:</p><ol><li>Display a “Download Full PDF Report” button on blog posts</li><li>Show a form modal when clicked to collect user information</li><li>Submit this information to a Notion database</li><li>Allow the user to download the PDF after form submission</li><li>Work in both development and production environments</li></ol><h3>The Architecture</h3><p>The final solution involved several key components:</p><ol><li>A DownloadPdfButton React component</li><li>An API route handler in src/api/pdf-form-submit.js</li><li>A forwarding API endpoint in api/pdf-form-submit/index.js</li><li>A custom Docusaurus plugin for API route handling</li><li>Development scripts to run both Docusaurus and API servers</li></ol><h3>Challenge 1: Understanding the Docusaurus API Route System</h3><h3>The Problem</h3><p>Unlike Next.js, Docusaurus doesn’t have a built-in API routes system. Our initial attempts to create API endpoints failed because we didn’t understand how API routes are handled in Docusaurus.</p><h3>The Solution</h3><p>We discovered that Docusaurus requires a custom plugin to handle API routes. This plugin sets up a proxy in development mode that forwards API requests to a separate Node.js server running on port 3001.</p><pre>// src/plugins/api-routes.js<br>module.exports = function apiRoutesPlugin(context, options) {<br>  return {<br>    name: &#39;docusaurus-api-routes-plugin&#39;,<br>    configureWebpack(config, isServer) {<br>      if (!isServer &amp;&amp; process.env.NODE_ENV === &#39;development&#39;) {<br>        return {<br>          devServer: {<br>            proxy: {<br>              &#39;/api&#39;: {<br>                target: &#39;http://localhost:3001&#39;,<br>                secure: false,<br>                changeOrigin: true,<br>                // Additional configuration...<br>              },<br>            },<br>          },<br>        };<br>      }<br>      return {};<br>    },<br>    // Additional plugin configuration...<br>  };<br>};</pre><p>This plugin must be registered in docusaurus.config.ts to work properly.</p><h3>Challenge 2: CORS and Request Handling</h3><h3>The Problem</h3><p>Even after setting up the API routes plugin, we encountered CORS errors when submitting the form, particularly in the development environment.</p><h3>The Solution</h3><p>We added proper CORS headers to our API server configuration and ensured that the proxy settings correctly handled both OPTIONS preflight requests and actual POST requests:</p><pre>// In the plugin&#39;s proxy configuration<br>onProxyRes: (proxyRes, req, res) =&gt; {<br>  // Add CORS headers to the response<br>  proxyRes.headers[&#39;Access-Control-Allow-Origin&#39;] = &#39;*&#39;;<br>  proxyRes.headers[&#39;Access-Control-Allow-Methods&#39;] = &#39;GET, POST, OPTIONS&#39;;<br>  proxyRes.headers[&#39;Access-Control-Allow-Headers&#39;] = &#39;Content-Type&#39;;<br>},</pre><h3>Challenge 3: API Server Configuration</h3><h3>The Problem</h3><p>We needed a separate API server to handle requests when in development mode, but weren’t sure how to structure it properly.</p><h3>The Solution</h3><p>We created an api-server.js file that:</p><ol><li>Sets up a Node.js HTTP server on port 3001</li><li>Loads environment variables from .env.local</li><li>Routes API requests to the appropriate handlers in the src/api directory</li><li>Provides proper error handling and logging</li></ol><p>For Vercel deployment, we created a parallel structure in the api folder that forwards requests to the main implementation.</p><h3>Challenge 4: Notion Database Integration</h3><h3>The Problem</h3><p>Connecting to Notion and storing form submissions required specific configuration and error handling to work reliably.</p><h3>The Solution</h3><p>We implemented a comprehensive Notion integration in our pdf-form-submit.js handler:</p><pre>// src/api/pdf-form-submit.js<br>const { Client } = require(&#39;@notionhq/client&#39;);<br>​<br>// Initialize Notion client<br>const notion = new Client({<br>  auth: process.env.NOTION_TOKEN,<br>});<br>​<br>async function handler(req, res) {<br>  // Validation and error handling<br>  <br>  // Create Notion page properties<br>  const properties = {<br>    Name: {<br>      title: [{ text: { content: fullName } }],<br>    },<br>    // Additional properties...<br>  };<br>​<br>  // Create the page in Notion<br>  const response = await notion.pages.create({<br>    parent: { database_id: process.env.PDF_FORM_DATABASE_ID },<br>    properties: properties,<br>  });<br>  <br>  // Return success response<br>}</pre><h3>Challenge 5: Development Environment Setup</h3><h3>The Problem</h3><p>Running both the Docusaurus server and the API server simultaneously was cumbersome and error-prone.</p><h3>The Solution</h3><p>We created a custom start-dev.js script that:</p><ol><li>Checks if required environment variables are present</li><li>Creates a default .env.local if it doesn&#39;t exist</li><li>Checks if necessary ports are available</li><li>Starts both servers in the correct order</li><li>Provides clear console logging for debugging</li></ol><pre>// start-dev.js<br>async function startServers() {<br>  // Start API server first<br>  const apiProcess = spawn(&#39;node&#39;, [&#39;api-server.js&#39;], { <br>    stdio: &#39;inherit&#39;,<br>    shell: true <br>  });<br>  <br>  // Wait a moment for API server to initialize<br>  await new Promise(resolve =&gt; setTimeout(resolve, 2000));<br>  <br>  // Start Docusaurus server<br>  const docusaurusProcess = spawn(&#39;npm&#39;, [&#39;run&#39;, &#39;start&#39;], { <br>    stdio: &#39;inherit&#39;,<br>    shell: true,<br>    env: { ...process.env, BROWSER: &#39;none&#39; }<br>  });<br>​<br>  // Handle process errors and shutdown<br>  // ...<br>}</pre><h3>Challenge 6: Environment Variables</h3><h3>The Problem</h3><p>Managing environment variables between development and production environments was challenging, especially for the Notion integration.</p><h3>The Solution</h3><p>We implemented a system that:</p><ol><li>Uses .env.local for local development</li><li>Relies on Vercel environment variables for production</li><li>Exposes necessary variables to client-side code through the plugin</li></ol><pre>// In the plugin&#39;s injectHtmlTags method<br>injectHtmlTags() {<br>  return {<br>    headTags: [<br>      {<br>        tagName: &#39;script&#39;,<br>        innerHTML: `<br>          window.ENV = {<br>            NOTION_DATABASE_ID: &#39;${process.env.NOTION_DATABASE_ID || &#39;&#39;}&#39;,<br>            // Other variables...<br>          };<br>        `,<br>      },<br>    ],<br>  };<br>},</pre><h3>The Final Implementation</h3><h3>Component Structure</h3><p>Our final DownloadPdfButton component:</p><ol><li>Shows a button that triggers a modal form</li><li>Collects user information</li><li>Submits the form to our API endpoint</li><li>Provides feedback on submission status</li><li>Opens the PDF link after successful submission</li></ol><h3>API Handler Structure</h3><p>The API handler follows these steps:</p><ol><li>Validates incoming request data</li><li>Formats the data for Notion</li><li>Creates a new page in the Notion database</li><li>Returns appropriate success/error responses</li></ol><h3>Development Setup</h3><p>To run the project locally:</p><ol><li>Create a .env.local file with required Notion credentials</li><li>Run node start-dev.js to start both servers</li><li>The Docusaurus site runs on port 3000, while the API server runs on port 3001</li></ol><h3>Production Deployment</h3><p>For Vercel deployment:</p><ol><li>Configure environment variables in the Vercel project settings</li><li>Vercel’s serverless functions automatically handle the API routes</li><li>The same code works in both environments thanks to our API forwarding structure</li></ol><h3>Key Lessons Learned</h3><ol><li><strong>Understand the Platform</strong>: Docusaurus handles API routes differently than frameworks like Next.js</li><li><strong>Dual Server Architecture</strong>: Running separate servers for frontend and API in development provides flexibility</li><li><strong>Error Logging</strong>: Comprehensive logging helps identify issues quickly</li><li><strong>Environment Consistency</strong>: Ensuring development and production environments are configured similarly prevents deployment surprises</li><li><strong>API Forwarding</strong>: Creating a forwarding structure ensures the same code works in both environments</li></ol><h3>Conclusion</h3><p>Building a PDF download component with Notion integration in Docusaurus requires understanding both the limitations of the platform and how to work around them. By setting up a custom plugin, implementing proper API handlers, and ensuring consistent environment configuration, we successfully created a solution that works both locally and in production.</p><p>This approach can be extended to other external integrations beyond Notion, making it a valuable pattern for any Docusaurus project that needs to handle form submissions or other API interactions.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=034e63e7a193" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Implementing Internationalization in a Docusaurus Website: Experience and Lessons Learned]]></title>
            <link>https://chanmeng666.medium.com/implementing-internationalization-in-a-docusaurus-website-experience-and-lessons-learned-b05139e33876?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/b05139e33876</guid>
            <category><![CDATA[docusaurus]]></category>
            <category><![CDATA[i18n]]></category>
            <category><![CDATA[internationalization]]></category>
            <category><![CDATA[experience]]></category>
            <category><![CDATA[web-development]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Wed, 14 May 2025 10:19:29 GMT</pubDate>
            <atom:updated>2025-05-14T10:19:29.761Z</atom:updated>
            <content:encoded><![CDATA[<h3>Project Overview</h3><p>This document details the process of implementing Chinese/English language switching for a Docusaurus-based website showcasing FemTech companies. The primary goal was to enable user interface elements on the /showcase page to be properly translated between English and Chinese.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iAH-tDBBShi6Dm5B65A9gg.png" /></figure><h3>Initial Requirements</h3><ul><li>Enable language switching between English (default) and Chinese (zh-Hans) for the /showcase page</li><li>Translate all UI elements including:</li></ul><p><em>Page headers and descriptions</em></p><p><em>Filter section title and buttons</em></p><p><em>Company card elements (founder/funding labels)</em></p><p><em>Category tags</em></p><h3>Testing Environment</h3><ul><li>Docusaurus version: 3.x</li><li>Development server: localhost:3000</li><li>Chinese version accessed via: localhost:3000/zh-Hans/showcase</li><li>English version accessed via: localhost:3000/showcase</li></ul><h3>Attempted Approaches</h3><h3>1. Standard Docusaurus i18n Translation Files</h3><p>The initial approach followed Docusaurus’s official internationalization method:</p><ol><li>Creating translation JSON files in the appropriate directories:</li></ol><ul><li>i18n/zh-Hans/docusaurus-plugin-content-pages/showcase.json</li><li>i18n/zh-Hans/docusaurus-theme-common.json</li><li>i18n/zh-Hans/theme.json</li></ul><p>2. Using Docusaurus’s built-in translation functions:</p><pre>import {translate} from &#39;@docusaurus/Translate&#39;;<br><br>translate({<br>  id: &#39;showcase.filters.title&#39;,<br>  message: &#39;Filters&#39;,<br>});</pre><p><strong>Results:</strong> Despite proper file placement and correct usage of the translation functions, translations were not applied. Debugging showed that while the locale was correctly detected as zh-Hans, the translation system returned English text.</p><h3>2. Modifying Translation Keys</h3><p>We attempted to modify the translation keys to match different patterns:</p><ol><li>Using theme-prefixed keys: theme.showcase.filters.title</li><li>Using plugin-specific directories: i18n/zh-Hans/docusaurus-theme-common/common.json</li></ol><p><strong>Results:</strong> These approaches didn’t resolve the issue. The translation mechanism still failed to pick up the correct translations.</p><h3>Successful Solution</h3><p>After several unsuccessful attempts with the built-in translation system, we implemented a direct conditional rendering approach:</p><pre>const {i18n: {currentLocale}} = useDocusaurusContext();<br>​<br>{currentLocale === &#39;zh-Hans&#39; ? &#39;筛选&#39; : translate({<br>  id: &#39;theme.showcase.filters.title&#39;,<br>  message: &#39;Filters&#39;<br>})}</pre><p>This approach:</p><ol><li>Uses useDocusaurusContext to detect the current locale</li><li>Renders Chinese text when the locale is zh-Hans</li><li>Falls back to English text via the translation function for other locales</li></ol><p>For component-specific translations like tags, we implemented a custom translation hook:</p><pre>export function useTranslatedTags() {<br>  const {i18n: {currentLocale}} = useDocusaurusContext();<br>  <br>  return useMemo(() =&gt; {<br>    // Function to get translation for a tag<br>    const getTranslatedTag = (tagKey: TagType) =&gt; {<br>      // Direct translations for Chinese locale<br>      if (currentLocale === &#39;zh-Hans&#39;) {<br>        const zhTranslations: Record&lt;TagType, {label: string; description: string}&gt; = {<br>          &#39;女性疾病&#39;: {<br>            label: &#39;女性疾病&#39;,<br>            description: &#39;专注于女性疾病的诊断、治疗和管理的公司&#39;<br>          },<br>          // Additional translations...<br>        };<br>        return zhTranslations[tagKey];<br>      }<br>      <br>      // Default to English for other locales<br>      return {<br>        label: translate({<br>          id: `femtech-tags.${tagKey}.label`,<br>          message: TagDefaults[tagKey].defaultLabel,<br>        }),<br>        description: translate({<br>          id: `femtech-tags.${tagKey}.description`,<br>          message: TagDefaults[tagKey].defaultDescription,<br>        }),<br>      };<br>    };<br>    <br>    // Create Tags object with translations...<br>  }, [currentLocale]);<br>}</pre><h3>File Structure Overview</h3><h3>Final Effective Files</h3><pre>src/<br>  components/<br>    ShowcaseFilters/index.tsx        # Contains filters with i18n support<br>    ShowcaseCards/index.tsx          # Displays company cards with i18n<br>    ClearAllButton/index.tsx         # Filter clearing button with i18n<br>    OperatorButton/index.tsx         # Filter operator with i18n<br>    ShowcaseCard/index.tsx           # Individual company card with i18n<br>  data/<br>    femtech-companies.ts             # Contains useTranslatedTags for tag i18n<br>  pages/<br>    showcase.tsx                     # English showcase page<br>i18n/<br>  zh-Hans/<br>    docusaurus-plugin-content-pages/<br>      showcase.tsx                   # Chinese version of showcase page</pre><h3>Deleted Unused Files</h3><pre>i18n/zh-Hans/docusaurus-theme-common.json      # Unused translation file<br>i18n/en/docusaurus-theme-common.json           # Unused translation file<br>i18n/zh-Hans/theme-common.json                 # Unused translation file<br>i18n/zh-Hans/theme.json                        # Unused translation file<br>i18n/zh-Hans/docusaurus-plugin-content-pages/showcase.json   # Unused translation file<br>i18n/en/docusaurus-plugin-content-pages/showcase.json        # Unused translation file<br>i18n/zh-Hans/docusaurus-theme-common/femtech-tags.json       # Unused translation file<br>i18n/en/docusaurus-theme-common/femtech-tags.json            # Unused translation file</pre><h3>Implementation Details</h3><h3>1. Page Header and Description Translations</h3><p><strong>English Version (</strong><strong>src/pages/showcase.tsx)</strong>:</p><pre>function ShowcaseHeader() {<br>  const {i18n: {currentLocale}} = useDocusaurusContext();<br>  <br>  return (<br>    &lt;section className=&quot;margin-top--lg margin-bottom--lg text--center&quot;&gt;<br>      &lt;Heading as=&quot;h1&quot;&gt;<br>        {translate({<br>          id: &#39;header.title&#39;,<br>          message: &#39;FemTech Companies Showcase&#39;,<br>        })}<br>      &lt;/Heading&gt;<br>      &lt;p&gt;<br>        {translate({<br>          id: &#39;header.description&#39;,<br>          message: &#39;Directory of innovative companies in the women\&#39;s health industry in China&#39;,<br>        })}<br>      &lt;/p&gt;<br>    &lt;/section&gt;<br>  );<br>}</pre><p><strong>Chinese Version (</strong><strong>i18n/zh-Hans/docusaurus-plugin-content-pages/showcase.tsx)</strong>:</p><pre>function ShowcaseHeader() {<br>  return (<br>    &lt;section className=&quot;margin-top--lg margin-bottom--lg text--center&quot;&gt;<br>      &lt;Heading as=&quot;h1&quot;&gt;<br>        中国女性健康公司展示<br>      &lt;/Heading&gt;<br>      &lt;p&gt;<br>        中国女性健康领域的创新企业列表<br>      &lt;/p&gt;<br>    &lt;/section&gt;<br>  );<br>}</pre><h3>2. UI Element Translations</h3><p><strong>Filters Title</strong>:</p><pre>function HeadingText() {<br>  const {i18n: {currentLocale}} = useDocusaurusContext();<br>  <br>  return (<br>    &lt;div className={styles.headingText}&gt;<br>      &lt;Heading as=&quot;h2&quot;&gt;<br>        {currentLocale === &#39;zh-Hans&#39; ? &#39;筛选&#39; : translate({<br>          id: &#39;theme.showcase.filters.title&#39;,<br>          message: &#39;Filters&#39;,<br>        })}<br>      &lt;/Heading&gt;<br>      &lt;span&gt;{companyCountPlural(filteredCompanies.length)}&lt;/span&gt;<br>    &lt;/div&gt;<br>  );<br>}</pre><p><strong>Clear Filters Button</strong>:</p><pre>function ClearAllButton() {<br>  const {i18n: {currentLocale}} = useDocusaurusContext();<br><br>  return (<br>    &lt;button<br>      type=&quot;button&quot;<br>      className=&quot;button button--sm button--outline button--danger&quot;<br>      onClick={clearAll}&gt;<br>      {currentLocale === &#39;zh-Hans&#39; ? &#39;清除筛选&#39; : translate({<br>        id: &#39;theme.showcase.filters.clearAll&#39;,<br>        message: &#39;Clear filters&#39;,<br>      })}<br>    &lt;/button&gt;<br>  );<br>}</pre><p><strong>Operator Button</strong>:</p><pre>function OperatorButton() {<br>  const [operator, toggleOperator] = useOperator();<br>  const {i18n: {currentLocale}} = useDocusaurusContext();<br>  <br>  return (<br>    &lt;button<br>      type=&quot;button&quot;<br>      className={`button button--sm button--${<br>        operator === &#39;OR&#39; ? &#39;secondary&#39; : &#39;primary&#39;<br>      }`}<br>      onClick={toggleOperator}&gt;<br>      {currentLocale === &#39;zh-Hans&#39; ? `筛选条件: ${operator}` : translate(<br>        {<br>          id: &#39;theme.showcase.filters.operator&#39;,<br>          message: &#39;Filter criteria: {operator}&#39;,<br>        },<br>        {<br>          operator,<br>        }<br>      )}<br>    &lt;/button&gt;<br>  );<br>}</pre><p><strong>Company Card Labels</strong>:</p><pre>// In ShowcaseCard component<br>{company.founders &amp;&amp; (<br>  &lt;div className={styles.showcaseCardDetail}&gt;<br>    &lt;strong&gt;<br>      {currentLocale === &#39;zh-Hans&#39; ? &#39;创始人:&#39; : translate({<br>        id: &#39;theme.showcase.card.founder&#39;,<br>        message: &#39;Founder:&#39;,<br>      })}<br>    &lt;/strong&gt; {company.founders}<br>  &lt;/div&gt;<br>)}<br>        <br>{latestFunding &amp;&amp; (<br>  &lt;div className={styles.showcaseCardDetail}&gt;<br>    &lt;strong&gt;<br>      {currentLocale === &#39;zh-Hans&#39; ? &#39;融资:&#39; : translate({<br>        id: &#39;theme.showcase.card.funding&#39;,<br>        message: &#39;Funding:&#39;,<br>      })}<br>    &lt;/strong&gt; {latestFunding.round} ({latestFunding.date}) {latestFunding.amount &amp;&amp; `- ${latestFunding.amount}`}<br>  &lt;/div&gt;<br>)}</pre><h3>Challenges Encountered</h3><h3>1. Translation File Loading Issues</h3><p>Despite following Docusaurus documentation, the translation files weren’t being properly loaded. The system would acknowledge the correct locale but wouldn’t use the translated strings from JSON files.</p><h3>2. Inconsistent Behavior</h3><p>Some translation keys would work with certain prefixes (theme. vs no prefix), while others wouldn&#39;t work regardless of the prefix used.</p><h3>3. Debugging Difficulties</h3><p>It was challenging to debug the translation system as there was limited feedback on why translations weren’t being applied. We added extensive debug logging to track:</p><ul><li>Current locale</li><li>Translation keys being used</li><li>Results of translation function calls</li></ul><h3>4. File Encoding</h3><p>At one point, a file displayed as garbled text, suggesting potential encoding issues with Chinese characters.</p><h3>Lessons Learned</h3><ol><li><strong>Docusaurus i18n Complexity</strong>: Docusaurus’s internationalization system requires precise file structure and naming conventions that may not be immediately intuitive.</li><li><strong>Conditional Rendering as an Alternative</strong>: Direct conditional rendering based on locale can be more reliable than the built-in translation system for complex use cases.</li><li><strong>Component-Level vs. Page-Level i18n</strong>: For page-level content, using separate files (showcase.tsx in each language folder) works well, while component-level UI elements benefit from inline conditional rendering.</li><li><strong>Translation Key Specificity</strong>: The translation key format is critical — using the wrong format or missing prefixes can cause translations to fail silently.</li><li><strong>Testing on Real Devices</strong>: What works in development may behave differently in production, so thorough testing across environments is essential.</li></ol><h3>Best Practices and Recommendations</h3><ol><li><strong>Choose the Right Approach</strong>:</li></ol><ul><li>For complete page translations: use separate language-specific files</li><li>For UI elements and components: use conditional rendering based on locale</li><li>For complex dynamic content (like tags): consider custom translation hooks</li></ul><p><strong>2. Simplify When Necessary</strong>:</p><ul><li>Don’t hesitate to bypass complex translation systems if direct approaches are more reliable</li><li>Hardcode translations directly in components when the standard i18n approach isn’t working</li></ul><p><strong>3. Consistent Implementation</strong>:</p><ul><li>Use the same approach across similar components for better maintainability</li><li>Document your chosen approach clearly for future developers</li></ul><p><strong>4. Clean Up Unused Files</strong>:</p><ul><li>Remove any unused translation files to prevent confusion and maintenance issues</li><li>Document which files are actually being used vs. which are legacy attempts</li></ul><p><strong>5. Proper Debugging</strong>:</p><ul><li>Add temporary logging to verify locale detection and translation lookups</li><li>Remove debug code once implementation is stable</li></ul><p><strong>6. Consider User Experience</strong>:</p><ul><li>Ensure all visible text is translated, including dynamic content like error messages</li><li>Test thoroughly with actual users of each supported language</li></ul><p>By following these guidelines and learning from the experiences documented here, future developers should be able to implement internationalization more effectively in Docusaurus projects.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b05139e33876" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Setting Up Algolia DocSearch in Docusaurus]]></title>
            <link>https://chanmeng666.medium.com/setting-up-algolia-docsearch-in-docusaurus-c87e1a48b783?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/c87e1a48b783</guid>
            <category><![CDATA[docsearch]]></category>
            <category><![CDATA[algolia-search]]></category>
            <category><![CDATA[docusaurus]]></category>
            <category><![CDATA[crawler]]></category>
            <category><![CDATA[algolia]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Fri, 09 May 2025 07:41:12 GMT</pubDate>
            <atom:updated>2025-05-09T07:41:12.181Z</atom:updated>
            <content:encoded><![CDATA[<h3>Introduction</h3><p>This guide will walk you through the complete process of setting up Algolia DocSearch in your Docusaurus project. Algolia DocSearch provides powerful search capabilities for your documentation website, allowing users to quickly find relevant content.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YKy5uhSt5dscFO9XxbtyeA.png" /></figure><h3>Prerequisites</h3><ul><li>A Docusaurus website (v2.x or v3.x)</li><li>An Algolia account (free tier is available)</li><li>Your website deployed and accessible online</li></ul><h3>Step 1: Sign Up for Algolia</h3><ol><li>Go to <a href="https://www.algolia.com/">Algolia’s website</a> and create an account</li><li>Once registered, you’ll receive an Application ID and API keys</li><li>Note down your Application ID, Search API Key, and Write API Key</li></ol><h3>Step 2: Configure Docusaurus</h3><p>In your Docusaurus project, open the docusaurus.config.ts (or docusaurus.config.js) file and add the Algolia configuration:</p><pre>// docusaurus.config.ts<br>export default {<br>  // ... existing configuration<br>  themeConfig: {<br>    // ... other theme config<br>    algolia: {<br>      // The application ID provided by Algolia<br>      appId: &#39;YOUR_APP_ID&#39;,<br>​<br>      // Public API key: it is safe to commit it<br>      apiKey: &#39;YOUR_SEARCH_API_KEY&#39;, // Use the Search API Key, not the Write API Key<br>​<br>      indexName: &#39;YOUR_INDEX_NAME&#39;, // Choose a meaningful name for your index<br>​<br>      // Optional: see doc section below<br>      contextualSearch: true,<br>​<br>      // Optional: Specify domains where the navigation should occur through window.location instead on history.push<br>      externalUrlRegex: &#39;external\\.com|domain\\.com&#39;,<br>​<br>      // Optional: Algolia search parameters<br>      searchParameters: {},<br>​<br>      // Optional: path for search page that enabled by default (`false` to disable it)<br>      searchPagePath: &#39;search&#39;,<br>​<br>      // Optional: whether the insights feature is enabled or not on Docsearch<br>      insights: false,<br>    },<br>  },<br>};</pre><p>Replace the placeholder values with your actual Algolia credentials.</p><h3>Step 3: Verify Domain Ownership</h3><p>Algolia requires verification of domain ownership before crawling your site:</p><ol><li>Go to the Algolia dashboard</li><li>Navigate to Crawler &gt; New Crawler</li><li>During setup, you’ll be asked to verify your domain</li><li>Choose the “robots.txt” verification method</li><li>Add the provided verification line to your robots.txt file</li></ol><p>If you don’t have a robots.txt file, create one in the static directory of your Docusaurus project:</p><pre>User-agent: *<br>Allow: /<br>​<br># Algolia-Crawler-Verif: YOUR_VERIFICATION_CODE<br>​<br>Sitemap: https://your-website-url.com/sitemap.xml</pre><h3>Step 4: Create and Configure the Crawler</h3><ol><li>In the Algolia dashboard, go to Crawler &gt; New Crawler</li><li>Enter a crawler name (this will be your index name)</li><li>Set your website URL as the start URL</li><li>Choose “Technical documentation” as the content type</li><li>Select “Docusaurus v2.x or v3.x” as the template</li><li>Click “Create”</li></ol><p>After creating the crawler, you’ll need to configure it:</p><ol><li>Verify your crawler settings match your website structure</li><li>Ensure the index name in the crawler matches the one in your Docusaurus configuration</li><li>Save your configuration</li></ol><h3>Step 5: Run the Crawler</h3><ol><li>In the Algolia dashboard, navigate to your crawler</li><li>Click “Run Crawler”</li><li>Wait for the crawling process to complete</li><li>Check that records have been added to your index</li></ol><h3>Step 6: Deploy Your Website</h3><p>Deploy your Docusaurus site with the updated configuration:</p><pre>git add .<br>git commit -m &quot;Add Algolia DocSearch configuration&quot;<br>git push</pre><h3>Step 7: Test the Search Functionality</h3><ol><li>Visit your deployed website</li><li>Click on the search icon in the header</li><li>Type a query related to your content</li><li>Verify that search results appear and links work correctly</li></ol><h3>Troubleshooting</h3><h3>No Search Results</h3><p>If you don’t see any search results:</p><ol><li>Check if your crawler has successfully indexed your site</li><li>Verify that the index name in your configuration matches the crawler’s index name</li><li>Ensure your API keys are correct</li></ol><h3>Broken Links in Search Results</h3><p>If clicking on search results leads to “Page Not Found” errors:</p><ol><li>Remove or adjust the replaceSearchResultPathname configuration in your docusaurus.config.ts:</li></ol><pre>// Comment out this section if it&#39;s causing issues<br>/*<br>replaceSearchResultPathname: {<br>  from: &#39;/docs/&#39;,<br>  to: &#39;/&#39;,<br>},<br>*/</pre><p>2. Re-deploy your website</p><p>3. Re-run the crawler if necessary</p><h3>Empty Index (0 Records)</h3><p>If your index shows “No records yet”:</p><ol><li>Check the crawler logs for any errors</li><li>Verify that your site is accessible to the crawler</li><li>Ensure your crawler configuration is correct</li><li>Try running the crawler again</li></ol><h3>Additional Tips</h3><ol><li><strong>Contextual Search</strong>: Keep contextualSearch: true to ensure search results are relevant to the current language and version of your docs.</li><li><strong>Custom Styling</strong>: You can customize the appearance of the search modal by adding CSS variables to your custom.css file.</li><li><strong>Regular Updates</strong>: Re-run your crawler periodically to keep your search index updated with new content.</li><li><strong>Multiple Indexes</strong>: If you have different types of content (docs, blog, etc.), you might need multiple indexes or a unified search approach.</li></ol><h3>Conclusion</h3><p>You’ve successfully set up Algolia DocSearch for your Docusaurus website! Your users can now quickly find relevant content using the powerful search functionality. Remember to update your search index regularly by re-running the crawler whenever you add significant new content.</p><p><em>For more advanced configurations, refer to the </em><a href="https://docsearch.algolia.com/docs/docsearch-v3"><em>Algolia DocSearch documentation</em></a><em> and the </em><a href="https://docusaurus.io/docs/search"><em>Docusaurus search documentation</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c87e1a48b783" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating a Minimalist Living Space — Lessons from The Art of Discarding]]></title>
            <link>https://chanmeng666.medium.com/creating-a-minimalist-living-space-lessons-from-the-art-of-discarding-8d3a6ed7418c?source=rss-3a918d78aa73------2</link>
            <guid isPermaLink="false">https://medium.com/p/8d3a6ed7418c</guid>
            <category><![CDATA[lifestyle]]></category>
            <category><![CDATA[minimalist]]></category>
            <category><![CDATA[organization]]></category>
            <category><![CDATA[minimalism]]></category>
            <category><![CDATA[less-is-more]]></category>
            <dc:creator><![CDATA[Chan Meng]]></dc:creator>
            <pubDate>Sun, 20 Apr 2025 12:05:36 GMT</pubDate>
            <atom:updated>2025-04-20T12:05:36.854Z</atom:updated>
            <content:encoded><![CDATA[<h3>Creating a Minimalist Living Space — Lessons from The Art of Discarding</h3><p>The pursuit of a minimalist living space is more than just an aesthetic choice — it’s a pathway to inner peace and clarity. Drawing inspiration from Yamashita Eiko’s transformative work “The Art of Discarding”, let’s explore how to create a living space that nurtures both body and soul.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j8aR-O2IeIK0qc2FiYAAVw.png" /></figure><h3>The Philosophy of Letting Go</h3><p>At its core, the minimalist living philosophy isn’t simply about discarding items — it’s about maintaining a healthy flow of energy in your living space. As Yamashita teaches, there are three fundamental aspects to this practice:</p><ul><li>Cutting: Breaking free from material desires</li><li>Discarding: Letting go of unnecessary items</li><li>Separating: Detaching from emotional connections to objects</li></ul><h3>The Three-Space Principle</h3><p>A truly minimalist home recognizes three essential types of spaces:</p><p>Hidden Storage Spaces (70% Rule)</p><ul><li>Closets, drawers, and cabinets should be only 70% full</li><li>Allows for easy access and natural flow</li><li>Creates breathing room for items</li></ul><p>Visible Storage Spaces (50% Rule)</p><ul><li>Display cabinets and open shelving</li><li>Keep only half full for aesthetic balance</li><li>Maintains visual harmony</li></ul><p>Display Spaces (10% Rule)</p><ul><li>Countertops and surface areas</li><li>Minimal decoration creates maximum impact</li><li>Follows the principle of “less is more”</li></ul><h3>Creating a Breathing Space</h3><p>A key insight from Yamashita’s work is the concept of creating a “breathing space.” This means:</p><ul><li>Removing stagnant energy by eliminating unused items</li><li>Maintaining clear pathways for movement</li><li>Allowing natural light to flow freely</li><li>Creating spaces between objects</li></ul><h3>The Practice of Mindful Selection</h3><p>When deciding what to keep in your space, apply these three criteria:</p><ol><li>Necessity: Is this item truly needed?</li><li>Functionality: Does it serve a current purpose?</li><li>Joy: Does it bring genuine happiness?</li></ol><h3>Implementation Steps</h3><p>Assessment</p><ul><li>Survey your entire living space</li><li>Identify problem areas</li><li>Document current state</li></ul><p>Categorization</p><ul><li>Sort items into distinct categories</li><li>Apply the three-space principle</li><li>Group similar items together</li></ul><p>Decision Making</p><ul><li>Use the mindful selection criteria</li><li>Be honest about each item’s value</li><li>Consider the space’s overall harmony</li></ul><p>Organization</p><ul><li>Implement storage solutions</li><li>Maintain proper spacing</li><li>Create sustainable systems</li></ul><h3>Benefits of a Minimalist Space</h3><p>The rewards of creating a minimalist living environment extend beyond the physical:</p><ul><li>Mental Clarity: Reduced visual clutter leads to clearer thinking</li><li>Reduced Stress: Organized spaces lower anxiety levels</li><li>Increased Productivity: Everything has its place and purpose</li><li>Better Sleep: Peaceful environments promote better rest</li><li>Enhanced Creativity: Clear spaces foster creative thinking</li></ul><h3>Maintaining the Balance</h3><p>Remember that minimalism is not about deprivation — it’s about optimization. The goal is to create a space that supports your life rather than complicating it. Regular maintenance includes:</p><ul><li>Daily quick tidying sessions</li><li>Weekly organization reviews</li><li>Monthly decluttering practices</li><li>Seasonal deep cleaning</li></ul><h3>Conclusion</h3><p>Creating a minimalist living space is a journey, not a destination. As Yamashita Eiko teaches, it’s about developing a new relationship with our possessions and our space. By implementing these principles, we can create homes that not only look beautiful but feel deeply nurturing to our well-being.</p><p>The key is to start small, be consistent, and remember that every item you choose to keep should earn its place in your space. Through this practice, we can create living environments that truly support our best lives.</p><p><em>Remember, the goal isn’t to create a spartan environment devoid of personality, but rather to craft a space where every item has purpose and meaning. In doing so, we create not just a minimalist space, but a sanctuary for modern living.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8d3a6ed7418c" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>