Public
Like
glimpse2-runbook-view-glimpse-save-login-react
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v28View latest version
The /glimpse/:id
endpoint currently displays Notion data as JSON in a <pre>
tag. The goal is to render this data as properly formatted HTML while maintaining the existing architecture and caching system.
- Backend fetches Notion data via
getGlimpseData()
in/backend/controllers/glimpse.controller.ts
- Data structure includes main page +
glimpseContent
array of related pages - Each glimpse content item has:
{id, properties, blocks}
whereblocks
is the direct array (notblocks.results
) - Frontend uses React with server-side data injection via
window.__INITIAL_DATA__
- Current display:
GlimpseView.tsx
renders JSON in<pre>
tag
Create /frontend/components/NotionRenderer.tsx
:
/** @jsxImportSource https://esm.sh/react@18.2.0 */
import React from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
import { BlockRenderer } from "./BlockRenderer.tsx";
interface NotionRendererProps {
data: any;
}
export function NotionRenderer({ data }: NotionRendererProps) {
if (!data) {
return <div className="text-gray-500">No data available</div>;
}
// Handle both direct data and wrapped service response
const mainPage = data.data || data;
const glimpseContent = mainPage.glimpseContent || [];
return (
<div className="space-y-8">
{/* Main Page Header */}
<div className="border-b pb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{getPageTitle(mainPage)}
</h1>
{/* Only show Name property if it exists and is not already shown as title */}
{mainPage.properties && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(mainPage.properties)
.filter(([key, value]) => key === 'Name' && (value as any)?.type !== 'title')
.map(([key, value]) => (
<PropertyRenderer key={key} name={key} property={value} />
))}
</div>
)}
</div>
{/* Glimpse Content */}
{glimpseContent.length > 0 ? (
<div className="space-y-8">
<h2 className="text-xl font-semibold text-gray-800">
Content ({glimpseContent.length} pages)
</h2>
{glimpseContent.map((contentPage: any, index: number) => (
<ContentPageRenderer key={contentPage.id || index} page={contentPage} />
))}
</div>
) : (
<div className="text-gray-500 italic">
No glimpse content available for this page.
</div>
)}
</div>
);
}
function getPageTitle(page: any): string {
// Try to extract title from properties
if (page.properties) {
const titleProp = Object.values(page.properties).find((prop: any) => prop.type === 'title');
if (titleProp && (titleProp as any).title?.[0]?.plain_text) {
return (titleProp as any).title[0].plain_text;
}
}
// Fallback to page object title if available
if (page.object === 'page' && page.properties?.title?.title?.[0]?.plain_text) {
return page.properties.title.title[0].plain_text;
}
return 'Untitled';
}
function PropertyRenderer({ name, property }: { name: string; property: any }) {
if (!property || property.type === 'title') return null;
const renderPropertyValue = () => {
switch (property.type) {
case 'rich_text':
return property.rich_text?.map((text: any, i: number) => (
<span key={i}>{text.plain_text}</span>
)).join('') || '';
case 'number':
return property.number?.toString() || '';
case 'select':
return property.select?.name || '';
case 'multi_select':
return property.multi_select?.map((item: any) => item.name).join(', ') || '';
case 'date':
return property.date?.start || '';
case 'checkbox':
return property.checkbox ? '✓' : '✗';
case 'url':
return property.url ? (
<a href={property.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
{property.url}
</a>
) : '';
case 'email':
return property.email ? (
<a href={`mailto:${property.email}`} className="text-blue-600 hover:underline">
{property.email}
</a>
) : '';
case 'phone_number':
return property.phone_number || '';
case 'relation':
return `${property.relation?.length || 0} related items`;
default:
return JSON.stringify(property);
}
};
const value = renderPropertyValue();
if (!value) return null;
return (
<div className="bg-gray-50 p-3 rounded">
<dt className="text-sm font-medium text-gray-600">{name}</dt>
<dd className="text-sm text-gray-900 mt-1">{value}</dd>
</div>
);
}
function ContentPageRenderer({ page }: { page: any }) {
const title = getPageTitle(page);
// CRITICAL: blocks are directly on page object, not page.blocks.results
const blocks = page.blocks || [];
return (
<div className="border rounded-lg p-6 bg-white">
{title !== 'Untitled' && (
<h3 className="text-lg font-medium text-gray-900 mb-4">{title}</h3>
)}
{/* Render blocks - NO page properties for content pages */}
{blocks.length > 0 ? (
<div className="prose max-w-none">
<BlockGroupRenderer blocks={blocks} />
</div>
) : (
<div className="text-gray-500 italic text-sm">
No content blocks found for this page.
</div>
)}
</div>
);
}
function BlockGroupRenderer({ blocks }: { blocks: any[] }) {
const groupedBlocks: any[] = [];
let currentGroup: any = null;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (block.type === 'numbered_list_item') {
if (!currentGroup || currentGroup.type !== 'numbered_list') {
// Start a new numbered list group
currentGroup = {
type: 'numbered_list',
items: [block]
};
groupedBlocks.push(currentGroup);
} else {
// Add to existing numbered list group
currentGroup.items.push(block);
}
} else if (block.type === 'bulleted_list_item') {
if (!currentGroup || currentGroup.type !== 'bulleted_list') {
// Start a new bulleted list group
currentGroup = {
type: 'bulleted_list',
items: [block]
};
groupedBlocks.push(currentGroup);
} else {
// Add to existing bulleted list group
currentGroup.items.push(block);
}
} else {
// Non-list item, reset current group
currentGroup = null;
groupedBlocks.push(block);
}
}
return (
<>
{groupedBlocks.map((item, index) => {
if (item.type === 'numbered_list') {
return (
<ol key={index} className="space-y-2 mb-4 ml-4" style={{
listStyleType: 'decimal',
listStylePosition: 'outside',
paddingLeft: '1.5rem'
}}>
{item.items.map((block: any, itemIndex: number) => (
<li key={block.id || itemIndex} className="pl-2">
<BlockRenderer block={block} listContext={{ type: 'numbered', index: itemIndex }} />
</li>
))}
</ol>
);
} else if (item.type === 'bulleted_list') {
return (
<ul key={index} className="space-y-2 mb-4 ml-4" style={{
listStyleType: 'disc',
listStylePosition: 'outside',
paddingLeft: '1.5rem'
}}>
{item.items.map((block: any, itemIndex: number) => (
<li key={block.id || itemIndex} className="pl-2">
<BlockRenderer block={block} listContext={{ type: 'bulleted', index: itemIndex }} />
</li>
))}
</ul>
);
} else {
return <BlockRenderer key={item.id || index} block={item} />;
}
})}
</>
);
}
Create /frontend/components/BlockRenderer.tsx
:
/** @jsxImportSource https://esm.sh/react@18.2.0 */
import React from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
interface BlockRendererProps {
block: any;
listContext?: {
type: 'numbered' | 'bulleted';
index: number;
};
}
// Helper function to group blocks for nested lists
function groupBlocks(blocks: any[]): any[] {
const groupedBlocks: any[] = [];
let currentGroup: any = null;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (block.type === 'numbered_list_item') {
if (!currentGroup || currentGroup.type !== 'numbered_list') {
currentGroup = {
type: 'numbered_list',
items: [block]
};
groupedBlocks.push(currentGroup);
} else {
currentGroup.items.push(block);
}
} else if (block.type === 'bulleted_list_item') {
if (!currentGroup || currentGroup.type !== 'bulleted_list') {
currentGroup = {
type: 'bulleted_list',
items: [block]
};
groupedBlocks.push(currentGroup);
} else {
currentGroup.items.push(block);
}
} else {
currentGroup = null;
groupedBlocks.push(block);
}
}
return groupedBlocks;
}
// Helper component to render grouped blocks
function NestedBlockRenderer({ blocks }: { blocks: any[] }) {
const groupedBlocks = groupBlocks(blocks);
return (
<>
{groupedBlocks.map((item, index) => {
if (item.type === 'numbered_list') {
return (
<ol key={index} className="space-y-1 mt-2 ml-4" style={{
listStyleType: 'decimal',
listStylePosition: 'outside',
paddingLeft: '1.5rem'
}}>
{item.items.map((block: any, itemIndex: number) => (
<li key={block.id || itemIndex} className="pl-2">
<BlockRenderer block={block} listContext={{ type: 'numbered', index: itemIndex }} />
</li>
))}
</ol>
);
} else if (item.type === 'bulleted_list') {
return (
<ul key={index} className="space-y-1 mt-2 ml-4" style={{
listStyleType: 'disc',
listStylePosition: 'outside',
paddingLeft: '1.5rem'
}}>
{item.items.map((block: any, itemIndex: number) => (
<li key={block.id || itemIndex} className="pl-2">
<BlockRenderer block={block} listContext={{ type: 'bulleted', index: itemIndex }} />
</li>
))}
</ul>
);
} else {
return <BlockRenderer key={item.id || index} block={item} />;
}
})}
</>
);
}
export function BlockRenderer({ block, listContext }: BlockRendererProps) {
if (!block || !block.type) {
return null;
}
const blockData = block[block.type] || {};
const children = block.children || [];
const renderChildren = () => {
if (children.length === 0) return null;
return (
<div className="ml-4 mt-2">
{children.map((child: any, index: number) => (
<BlockRenderer key={child.id || index} block={child} />
))}
</div>
);
};
switch (block.type) {
case 'paragraph':
return (
<div className="mb-4">
<RichTextRenderer richText={blockData?.rich_text || []} />
{renderChildren()}
</div>
);
case 'heading_1':
return (
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
<RichTextRenderer richText={blockData?.rich_text || []} />
</h1>
{renderChildren()}
</div>
);
case 'heading_2':
return (
<div className="mb-5">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
<RichTextRenderer richText={blockData?.rich_text || []} />
</h2>
{renderChildren()}
</div>
);
case 'heading_3':
return (
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-2">
<RichTextRenderer richText={blockData?.rich_text || []} />
</h3>
{renderChildren()}
</div>
);
case 'bulleted_list_item':
if (listContext?.type === 'bulleted') {
// When rendered inside <ul>, render content inline to avoid line breaks after bullets
return (
<>
<RichTextRenderer richText={blockData?.rich_text || []} />
{children.length > 0 && (
<NestedBlockRenderer blocks={children} />
)}
</>
);
}
// Fallback for standalone bulleted list items
return (
<div className="mb-2">
<div className="flex items-start">
<span className="mr-2 mt-2 w-1 h-1 bg-gray-600 rounded-full flex-shrink-0"></span>
<div className="flex-1">
<RichTextRenderer richText={blockData?.rich_text || []} />
{renderChildren()}
</div>
</div>
</div>
);
case 'numbered_list_item':
if (listContext?.type === 'numbered') {
// When rendered inside <ol>, render content inline to avoid line breaks after numbers
return (
<>
<RichTextRenderer richText={blockData?.rich_text || []} />
{children.length > 0 && (
<NestedBlockRenderer blocks={children} />
)}
</>
);
}
// Fallback for standalone numbered list items
return (
<div className="mb-2">
<div className="flex items-start">
<span className="mr-2 text-gray-600 font-medium min-w-[1.5rem]">1.</span>
<div className="flex-1">
<RichTextRenderer richText={blockData?.rich_text || []} />
{renderChildren()}
</div>
</div>
</div>
);
case 'to_do':
return (
<div className="mb-2">
<div className="flex items-start">
<input
type="checkbox"
checked={blockData?.checked || false}
readOnly
className="mr-2 mt-1"
/>
<div className="flex-1">
<RichTextRenderer richText={blockData?.rich_text || []} />
{renderChildren()}
</div>
</div>
</div>
);
case 'quote':
return (
<div className="mb-4">
<blockquote className="border-l-4 border-gray-300 pl-4 italic text-gray-700">
<RichTextRenderer richText={blockData?.rich_text || []} />
</blockquote>
{renderChildren()}
</div>
);
case 'code':
return (
<div className="mb-4">
<pre className="bg-gray-100 border rounded p-4 overflow-x-auto">
<code className={`language-${blockData?.language || 'text'}`}>
<RichTextRenderer richText={blockData?.rich_text || []} />
</code>
</pre>
{renderChildren()}
</div>
);
case 'callout':
const renderCalloutIcon = () => {
if (!blockData?.icon) return '💡';
if (blockData.icon.type === 'emoji') {
return blockData.icon.emoji;
} else if (blockData.icon.type === 'external' && blockData.icon.external?.url) {
return (
<img
src={blockData.icon.external.url}
alt="Callout icon"
className="w-5 h-5"
/>
);
} else if (blockData.icon.type === 'file' && blockData.icon.file?.url) {
return (
<img
src={blockData.icon.file.url}
alt="Callout icon"
className="w-5 h-5"
/>
);
}
return '💡';
};
return (
<div className="mb-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start">
<span className="mr-2 text-lg flex-shrink-0 flex items-center">
{renderCalloutIcon()}
</span>
<div className="flex-1">
<RichTextRenderer richText={blockData?.rich_text || []} />
{/* CRITICAL: Render children INSIDE callout container */}
{children.length > 0 && (
<div className="mt-3">
{children.map((child: any, index: number) => (
<BlockRenderer key={child.id || index} block={child} />
))}
</div>
)}
</div>
</div>
</div>
</div>
);
case 'divider':
return (
<div className="mb-6">
<hr className="border-gray-300" />
{renderChildren()}
</div>
);
case 'image':
const imageUrl = blockData?.file?.url || blockData?.external?.url;
const caption = blockData?.caption || [];
return (
<div className="mb-4">
{imageUrl && (
<img
src={imageUrl}
alt={caption.length > 0 ? caption[0].plain_text : 'Image'}
className="max-w-full h-auto rounded"
/>
)}
{caption.length > 0 && (
<p className="text-sm text-gray-600 mt-2 italic">
<RichTextRenderer richText={caption} />
</p>
)}
{renderChildren()}
</div>
);
case 'video':
const videoUrl = blockData?.file?.url || blockData?.external?.url;
return (
<div className="mb-4">
{videoUrl && (
<video controls className="max-w-full h-auto rounded">
<source src={videoUrl} />
Your browser does not support the video tag.
</video>
)}
{renderChildren()}
</div>
);
case 'embed':
return (
<div className="mb-4">
<div className="bg-gray-100 border rounded p-4">
<p className="text-sm text-gray-600">
Embedded content: <a href={blockData?.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{blockData?.url}</a>
</p>
</div>
{renderChildren()}
</div>
);
case 'bookmark':
return (
<div className="mb-4">
<a
href={blockData?.url}
target="_blank"
rel="noopener noreferrer"
className="block border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="text-blue-600 hover:underline font-medium">
{blockData?.caption?.[0]?.plain_text || blockData?.url}
</div>
<div className="text-sm text-gray-500 mt-1">{blockData?.url}</div>
</a>
{renderChildren()}
</div>
);
case 'table':
return (
<div className="mb-4 overflow-x-auto">
<table className="min-w-full border border-gray-300">
<tbody>
{children.map((row: any, rowIndex: number) => (
<tr key={row.id || rowIndex} className="border-b border-gray-300">
{row.table_row?.cells?.map((cell: any, cellIndex: number) => (
<td key={cellIndex} className="border-r border-gray-300 p-2">
<RichTextRenderer richText={cell || []} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case 'table_row':
return null;
default:
return (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm text-yellow-800">
Unsupported block type: <code className="font-mono">{block.type}</code>
</p>
{blockData?.rich_text && (
<div className="mt-2">
<RichTextRenderer richText={blockData.rich_text} />
</div>
)}
{renderChildren()}
</div>
);
}
}
function RichTextRenderer({ richText }: { richText: any[] }) {
if (!richText || richText.length === 0) {
return null;
}
return (
<>
{richText.map((text, index) => {
let element = <span key={index}>{text.plain_text}</span>;
if (text.annotations) {
if (text.annotations.bold) {
element = <strong key={index}>{element}</strong>;
}
if (text.annotations.italic) {
element = <em key={index}>{element}</em>;
}
if (text.annotations.strikethrough) {
element = <del key={index}>{element}</del>;
}
if (text.annotations.underline) {
element = <u key={index}>{element}</u>;
}
if (text.annotations.code) {
element = <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{text.plain_text}</code>;
}
if (text.annotations.color && text.annotations.color !== 'default') {
const colorClass = getColorClass(text.annotations.color);
element = <span key={index} className={colorClass}>{element}</span>;
}
}
if (text.href) {
element = (
<a
key={index}
href={text.href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{element}
</a>
);
}
return element;
})}
</>
);
}
function getColorClass(color: string): string {
const colorMap: { [key: string]: string } = {
'gray': 'text-gray-600',
'brown': 'text-amber-700',
'orange': 'text-orange-600',
'yellow': 'text-yellow-600',
'green': 'text-green-600',
'blue': 'text-blue-600',
'purple': 'text-purple-600',
'pink': 'text-pink-600',
'red': 'text-red-600',
'gray_background': 'bg-gray-100',
'brown_background': 'bg-amber-100',
'orange_background': 'bg-orange-100',
'yellow_background': 'bg-yellow-100',
'green_background': 'bg-green-100',
'blue_background': 'bg-blue-100',
'purple_background': 'bg-purple-100',
'pink_background': 'bg-pink-100',
'red_background': 'bg-red-100',
};
return colorMap[color] || '';
}
Modify /frontend/components/GlimpseView.tsx
:
// Add import
import React, { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
import { NotionRenderer } from "./NotionRenderer.tsx";
// Add state to component
export function GlimpseView({ data, userEmail }: GlimpseViewProps) {
const [showDebug, setShowDebug] = useState(false);
// Replace main content section
<main className="flex-1 p-6 overflow-auto">
<div className="max-w-full">
{/* Debug toggle */}
<div className="mb-4 flex items-center gap-4">
<button
onClick={() => setShowDebug(!showDebug)}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded border"
>
{showDebug ? 'Hide' : 'Show'} Raw JSON
</button>
</div>
{showDebug ? (
<div>
<h2 className="text-lg font-medium text-gray-900 mb-4">Raw Glimpse Data</h2>
<pre className="bg-white border rounded-lg p-4 text-sm overflow-auto whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
</pre>
</div>
) : (
<NotionRenderer data={data} />
)}
</div>
</main>
Update /frontend/README.md
to document the new HTML rendering functionality and supported block types.
- Data Structure: The service returns item.blocks.results which the controller assigns to blocks property, so the frontend receives glimpseContent[].blocks as a direct array (not nested under a results property)"
- Callout Children: Must render children INSIDE the callout container, not after it
- Icon Handling: Support emoji, external, and file icon types for callouts
- Property Filtering: Only show "Name" property for main page, no properties for content pages
- Rich Text: Handle all formatting (bold, italic, colors, links) and nested structures
- Error Handling: Provide fallbacks for missing data and unsupported block types
- List Context: Pass
listContext
parameter to BlockRenderer for proper list item rendering - List Formatting: Use
listStylePosition: 'outside'
to prevent line breaks after numbers/bullets - Nested List Rendering: List item content must render as React Fragments (
<>
) when inside grouped lists to avoid block-level line breaks - List Grouping: Consecutive list items must be grouped into proper
<ol>
and<ul>
elements for correct browser numbering - Nested List Helpers: Use
NestedBlockRenderer
component for handling nested lists within list items - Browser-Native Numbering: Leverage browser's automatic list numbering with semantic HTML elements
/glimpse/:id
renders formatted HTML instead of JSON- Debug toggle allows switching between HTML and JSON views
- All major Notion block types render correctly with proper styling
- Nested content (especially in callouts) is properly contained
- Custom icons display correctly in callouts
- Clean layout with minimal metadata display
- Proper list numbering with browser-native
<ol>
and<ul>
elements - No line breaks after list numbers or bullets
- Correct nested list numbering (nested lists start from 1)
- Proper indentation for nested lists