diff --git a/package.json b/package.json index f41a3d9..b4450de 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,11 @@ "@payloadcms/plugin-seo": "3.72.0", "@payloadcms/richtext-lexical": "3.72.0", "@payloadcms/ui": "3.72.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.2.6", "@supabase/supabase-js": "^2.49.1", diff --git a/screen.png b/screen.png new file mode 100644 index 0000000..80e706d Binary files /dev/null and b/screen.png differ diff --git a/src/app/(frontend)/globals.css b/src/app/(frontend)/globals.css index 2057a5f..1fb817d 100644 --- a/src/app/(frontend)/globals.css +++ b/src/app/(frontend)/globals.css @@ -71,6 +71,11 @@ --neutral-900: 0.15 0 0; --neutral-950: 0.1 0 0; --neutral-black: 0 0 0; + + /* Background colors with subtle blue tinge */ + --bg-primary: 0.08 0.01 245; + --bg-secondary: 0.12 0.01 245; + --bg-tertiary: 0.16 0.01 245; } [data-theme='dark'] { @@ -129,6 +134,11 @@ --neutral-900: 0.15 0 0; --neutral-950: 0.1 0 0; --neutral-black: 0 0 0; + + /* Background colors with subtle blue tinge */ + --bg-primary: 0.08 0.01 245; + --bg-secondary: 0.12 0.01 245; + --bg-tertiary: 0.16 0.01 245; } } diff --git a/src/app/(frontend)/maintenance/page.tsx b/src/app/(frontend)/maintenance/page.tsx index edadf88..4218095 100644 --- a/src/app/(frontend)/maintenance/page.tsx +++ b/src/app/(frontend)/maintenance/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import Image from 'next/image' -import { useState } from 'react' +import Image from 'next/image'; +import { useState } from 'react'; export default function MaintenancePage() { - const [imageLoaded, setImageLoaded] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false); return (
@@ -12,12 +12,11 @@ export default function MaintenancePage() { Background future space image {/* Overlay */} -
+
{/* Centered content */}
@@ -51,7 +50,7 @@ export default function MaintenancePage() { {/* Text */}
{imageLoaded ? ( -

+

This site is under construction. Come back soon!

) : ( @@ -62,5 +61,5 @@ export default function MaintenancePage() {
- ) + ); } diff --git a/src/app/(frontend)/posts/[slug]/page.tsx b/src/app/(frontend)/posts/[slug]/page.tsx index d0bc0ed..d746df8 100644 --- a/src/app/(frontend)/posts/[slug]/page.tsx +++ b/src/app/(frontend)/posts/[slug]/page.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { RelatedPosts } from '@/blocks/RelatedPosts/Component'; import { PayloadRedirects } from '@/components/PayloadRedirects'; +import { Header } from '@/components/Header'; import configPromise from '@payload-config'; import { getPayload } from 'payload'; import { draftMode } from 'next/headers'; @@ -99,47 +100,81 @@ export default async function Post({ params: paramsPromise }: Args) { dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbStructuredData) }} /> -
- {/* Hidden metadata for Schema.org */} - - {post.shortDescription && } - {post.publishedAt && } - {post.updatedAt && } - {post.heroImage && typeof post.heroImage === 'object' && 'url' in post.heroImage && ( - - )} - {post.populatedAuthors?.map((author, index) => ( - - ))} - - - - {/* Allows redirects for valid pages too */} - - - {draft && } - - - - {/* Main content area with improved readability */} -
-
- {/* Original article link */} - {post.url && ( -
- -
+ {/* Header - Fixed at top */} +
+ + {/* Content with padding for fixed header */} +
+ {/* Hidden metadata for Schema.org */} + + {post.shortDescription && } + {post.publishedAt && } + {post.updatedAt && } + {post.heroImage && typeof post.heroImage === 'object' && 'url' in post.heroImage && ( + + )} + {post.populatedAuthors?.map((author, index) => ( + + ))} + + + + {/* Allows redirects for valid pages too */} + + + {draft && } + + + + {/* Main content area with improved readability */} +
+
+ {/* Original article link */} + {post.url && ( + + )} + + {/* Article summary/excerpt for SEO */} + {post.shortDescription && ( +
+
+

+ Article Summary +

+

+ {post.shortDescription} +

- - - - -
- )} - - {/* Article summary/excerpt for SEO */} - {post.shortDescription && ( -
-
-

- Article Summary -

-

- {post.shortDescription} -

-
- )} - - {/* Content container with optimal reading width */} -
- {/* Article metadata for SEO */} -
- {post.updatedAt && post.updatedAt !== post.createdAt && ( -
- - - - - Last updated:{' '} - - + )} + + {/* Content container with optimal reading width */} +
+ {/* Article metadata for SEO */} +
+ {post.updatedAt && post.updatedAt !== post.createdAt && ( +
+ + + + + Last updated:{' '} + + +
+ )} +
+ + {/* Main article content */} +
+ +
+ + {/* Article tags/keywords */} + {post.categories && post.categories.length > 0 && ( +
+

+ Topics Covered +

+
+ {post.categories.map((category, index) => { + if (typeof category === 'object' && category !== null) { + return ( + + {category.title} + + ); + } + return null; + })} +
)}
- {/* Main article content */} -
- -
- - {/* Article tags/keywords */} - {post.categories && post.categories.length > 0 && ( -
-

- Topics Covered -

-
- {post.categories.map((category, index) => { - if (typeof category === 'object' && category !== null) { + {/* Author bio section */} + {post.populatedAuthors && post.populatedAuthors.length > 0 && ( +
+
+

About the Author

+ {post.populatedAuthors.map((author, index) => { + if (author?.name) { return ( - - {category.title} - +
+
+ {author.name.charAt(0).toUpperCase()} +
+
+
+

+ {author.name} +

+

Contributor at Decentralizard

+
+
); } return null; @@ -253,97 +311,63 @@ export default async function Post({ params: paramsPromise }: Args) {
)} -
- {/* Author bio section */} - {post.populatedAuthors && post.populatedAuthors.length > 0 && ( -
-
-

About the Author

- {post.populatedAuthors.map((author, index) => { - if (author?.name) { - return ( -
-
-
- {author.name.charAt(0).toUpperCase()} -
-
-
-

- {author.name} -

-

Contributor at Decentralizard

+ {/* Category-based related posts */} + {categoryBasedPosts.length > 0 && ( +
+

+ More in{' '} + {typeof firstCategory === 'object' ? firstCategory?.title : 'This Category'} +

+
+ {categoryBasedPosts.map((relatedPost) => ( + + {relatedPost.heroImage && typeof relatedPost.heroImage === 'object' && ( +
+ {relatedPost.title}
+ )} +
+

+ {relatedPost.title} +

+ {relatedPost.shortDescription && ( +

+ {relatedPost.shortDescription} +

+ )}
- ); - } - return null; - })} + + ))} +
-
- )} - - {/* Category-based related posts */} - {categoryBasedPosts.length > 0 && ( -
-

- More in{' '} - {typeof firstCategory === 'object' ? firstCategory?.title : 'This Category'} -

-
- {categoryBasedPosts.map((relatedPost) => ( - - {relatedPost.heroImage && typeof relatedPost.heroImage === 'object' && ( -
- {relatedPost.title} -
- )} -
-

- {relatedPost.title} -

- {relatedPost.shortDescription && ( -

- {relatedPost.shortDescription} -

- )} -
- - ))} + )} + + {/* Manually curated related posts */} + {post.relatedPosts && post.relatedPosts.length > 0 && ( +
+

+ Related Articles +

+ typeof post === 'object')} + />
-
- )} - - {/* Manually curated related posts */} - {post.relatedPosts && post.relatedPosts.length > 0 && ( -
-

- Related Articles -

- typeof post === 'object')} - /> -
- )} + )} +
-
-
+
+ ); } diff --git a/src/base/accordion.tsx b/src/base/accordion.tsx new file mode 100644 index 0000000..8f89550 --- /dev/null +++ b/src/base/accordion.tsx @@ -0,0 +1,54 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; + +import { cn } from '@/utilities/ui'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/base/slider.tsx b/src/base/slider.tsx new file mode 100644 index 0000000..0ce5b1c --- /dev/null +++ b/src/base/slider.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; + +import { cn } from '@/utilities/ui'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/src/components/ArticleAnalyser/ArticleTreeMap.tsx b/src/components/ArticleAnalyser/ArticleTreeMap.tsx new file mode 100644 index 0000000..1d64da7 --- /dev/null +++ b/src/components/ArticleAnalyser/ArticleTreeMap.tsx @@ -0,0 +1,739 @@ +import React, { FC, useState, useEffect, useMemo, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import * as d3 from 'd3'; +import { Post } from '@/payload-types'; +import { useCanvasStore } from '@/stores/useCanvasStore'; +import { useFilteredPosts } from '@/stores/useFilterStore'; +import { useZoomStore } from '@/stores/useZoomStore'; +import { SizeMetric, PostWithMetrics, HierarchyNode, ViewType } from './types'; +import { + getShade, + enrichPostsWithMetrics, + CATEGORY_PALETTE, + PREDEFINED_CATEGORY_COLORS, +} from './utils'; +import { FilterSidebar } from './FilterSidebar'; +import { cn } from '@/utilities/ui'; + +const TARGET_NODE_COUNT = 40; // Target number of articles to display at any zoom level + +interface ArticleTreeMapProps { + posts: Post[]; + activeView?: ViewType; + onViewChange?: (view: ViewType) => void; +} + +export const ArticleTreeMap: FC = ({ posts, activeView, onViewChange }) => { + const router = useRouter(); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const zoomBehaviorRef = useRef | null>(null); + const [sizeMetric] = useState('clicks'); + const [postsWithMetrics, setPostsWithMetrics] = useState([]); + const [isNavigating, setIsNavigating] = useState(false); + const [clickedArticle, setClickedArticle] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + // Use Zustand store for filtered posts + const filteredPosts = useFilteredPosts(posts); + + // Use Zustand store for zoom level + const { zoomLevel, setZoomLevel } = useZoomStore(); + + // Store the current zoom state (using zoomLevel from store as scale) + const [zoomState, setZoomState] = useState({ + scale: zoomLevel, + centerX: 0, + centerY: 0, + visibleRegion: { x: 0, y: 0, width: 0, height: 0 }, + }); + + // Cache the treemap nodes after layout computation + const treemapNodesRef = useRef[]>([]); + const allNodesRef = useRef[]>([]); + + // For hover interactions + const [hoveredNode, setHoveredNode] = useState | null>( + null + ); + const { dimensions, setDimensions } = useCanvasStore(); + const [isTransitioning, setIsTransitioning] = useState(false); + + // Track the full hierarchy data + const hierarchyRootRef = useRef | null>(null); + + // Set up canvas dimensions based on container + useEffect(() => { + const handleResize = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDimensions({ + width: rect.width, + height: rect.height, + }); + } + }; + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [setDimensions]); + + // Enrich posts with deterministic metrics (consistent across SSR and client) + useEffect(() => { + setPostsWithMetrics(enrichPostsWithMetrics(filteredPosts)); + }, [filteredPosts]); + + // Build hierarchical data for the treemap + const hierarchyData: HierarchyNode = useMemo(() => { + const categorized: Record = {}; + postsWithMetrics.forEach((post) => { + const category = + post.category_titles && post.category_titles.length > 0 + ? post.category_titles[0] + : 'Uncategorized'; + if (!categorized[category]) categorized[category] = []; + categorized[category].push(post); + }); + return { + name: 'Articles', + children: Object.keys(categorized).map((category) => ({ + name: category, + children: categorized[category].map((post) => ({ + name: post.title.replace(/\n\s*/g, ' '), + clicks: post.clicks, + uniqueClicks: post.uniqueClicks, + clickRate: post.clickRate, + slug: post.slug, + id: post.id, + })), + })), + }; + }, [postsWithMetrics]); + + // Limit articles to target density + const limitArticlesToTargetDensity = ( + nodes: d3.HierarchyRectangularNode[], + targetCount: number, + zoomScale: number + ) => { + // If we have fewer nodes than target, return all of them + if (nodes.length <= targetCount) return nodes; + + // Filter for leaf nodes (articles) and category nodes + const categoryNodes = nodes.filter((n) => n.depth === 1); + const leafNodes = nodes.filter((n) => !n.children && n.depth > 1); + + if (leafNodes.length <= targetCount) { + // If we have fewer leaf nodes than target, return all categories and leaves + return [...categoryNodes, ...leafNodes]; + } + + // When highly zoomed in, prioritize visible articles with higher values + if (zoomScale > 2) { + const sortedLeaves = [...leafNodes].sort((a, b) => (b.value || 0) - (a.value || 0)); + return [...categoryNodes, ...sortedLeaves.slice(0, targetCount - categoryNodes.length)]; + } + + // When moderately zoomed, group smaller articles by their parent category + const topLeaves = leafNodes + .sort((a, b) => (b.value || 0) - (a.value || 0)) + .slice(0, Math.floor(targetCount * 0.7)); + + // Return the categories, top leaves + return [...categoryNodes, ...topLeaves]; + }; + + // Calculate which nodes to display based on visible region + const getNodesInVisibleRegion = (zoomState: { + scale: number; + centerX: number; + centerY: number; + visibleRegion: { x: number; y: number; width: number; height: number }; + }) => { + if (!allNodesRef.current.length) return []; + + const { x, y, width, height } = zoomState.visibleRegion; + + // Find nodes that intersect with the visible region + const visibleNodes = allNodesRef.current.filter((node) => { + return node.x0 < x + width && node.x1 > x && node.y0 < y + height && node.y1 > y; + }); + + // Limit the number of nodes to a reasonable density + return limitArticlesToTargetDensity(visibleNodes, TARGET_NODE_COUNT, zoomState.scale); + }; + + // Compute treemap layout for visible nodes + const computeVisibleTreemap = (visibleNodes: d3.HierarchyRectangularNode[]) => { + if (!visibleNodes.length) return; + + // Get category nodes and leaf nodes + const categoryNodes = visibleNodes.filter((n) => n.depth === 1); + const leafNodes = visibleNodes.filter((n) => !n.children && n.depth > 1); + + // Create a new temporary hierarchy for layout + const tempHierarchy: HierarchyNode = { + name: 'Visible Articles', + children: categoryNodes + .map((catNode) => ({ + name: catNode.data.name, + children: leafNodes + .filter((leaf) => leaf.parent?.data.name === catNode.data.name) + .map((leaf) => leaf.data), + })) + .filter((cat) => cat.children && cat.children.length > 0), + }; + + // Create a new hierarchy and compute the treemap + const root = d3 + .hierarchy(tempHierarchy) + .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0)) + .sort((a, b) => (b.value || 0) - (a.value || 0)); + + const treemapLayout = d3 + .treemap() + .size([dimensions.width, dimensions.height]) + .paddingOuter(3) + .paddingTop(22) + .paddingInner(1) + .round(true); + + treemapLayout(root); + + // Store the new layout + treemapNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[]; + }; + + // Initial computation of the full treemap + useEffect(() => { + if (!hierarchyData.children || hierarchyData.children.length === 0) return; + + // Create the full hierarchy + const root = d3 + .hierarchy(hierarchyData) + .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0)) + .sort((a, b) => (b.value || 0) - (a.value || 0)); + + hierarchyRootRef.current = root; + + // Compute the initial treemap for all nodes + const treemapLayout = d3 + .treemap() + .size([dimensions.width, dimensions.height]) + .paddingOuter(3) + .paddingTop(22) + .paddingInner(1) + .round(true); + + treemapLayout(root); + + // Store all nodes for reference + allNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[]; + treemapNodesRef.current = allNodesRef.current; + + // Update visible region based on dimensions + setZoomState((prev) => ({ + ...prev, + visibleRegion: { + x: 0, + y: 0, + width: dimensions.width, + height: dimensions.height, + }, + })); + + // After computing the layout, draw the treemap + draw(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hierarchyData, sizeMetric, dimensions]); + + // When zoom state changes, recalculate visible nodes + useEffect(() => { + if (allNodesRef.current.length === 0) return; + + const visibleNodes = getNodesInVisibleRegion(zoomState); + + if (zoomState.scale > 1.2) { + // When zoomed in enough, compute a new treemap layout for just the visible nodes + computeVisibleTreemap(visibleNodes); + } else { + // When at default zoom, use the full treemap + treemapNodesRef.current = allNodesRef.current; + } + + draw(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomState]); + + const renderConstants = useMemo(() => { + if (!treemapNodesRef.current.length) { + return null; + } + const categoryNodes = treemapNodesRef.current.filter((d) => d.depth === 1); + const allCategories = categoryNodes.map((d) => d.data.name); + const otherCategories = allCategories.filter((cat) => !(cat in PREDEFINED_CATEGORY_COLORS)); + const ordinal = d3 + .scaleOrdinal() + .domain(otherCategories) + .range(CATEGORY_PALETTE.slice(2)); + const categoryMax: Record = {}; + categoryNodes.forEach((catNode) => { + let maxVal = 0; + catNode.leaves().forEach((leaf) => { + maxVal = Math.max(maxVal, leaf.data[sizeMetric] || 0); + }); + categoryMax[catNode.data.name] = maxVal; + }); + return { categoryNodes, ordinal, categoryMax }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [treemapNodesRef.current.length, sizeMetric]); + + // Optimize the canvas setup + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = dimensions.width * dpr; + canvas.height = dimensions.height * dpr; + canvas.style.width = `${dimensions.width}px`; + canvas.style.height = `${dimensions.height}px`; + + const context = canvas.getContext('2d'); + if (context) { + context.scale(dpr, dpr); + } + }, [dimensions]); + + // Draw the treemap + const draw = () => { + const canvas = canvasRef.current; + if (!canvas) return; + + const context = canvas.getContext('2d'); + if (!context) return; + + context.save(); + context.clearRect(0, 0, canvas.width, canvas.height); + + const rc = renderConstants; + if (!rc) { + context.restore(); + return; + } + const { ordinal, categoryMax } = rc; + + treemapNodesRef.current.forEach((d) => { + const x = d.x0, + y = d.y0, + w = d.x1 - d.x0, + h = d.y1 - d.y0; + if (w <= 0 || h <= 0) return; + + // Use different fills based on node type + if (d.children) { + // Category node with a colored background + const categoryName = d.data.name; + const baseColor = + PREDEFINED_CATEGORY_COLORS[categoryName] || ordinal(categoryName) || '#333333'; + context.fillStyle = d3.color(baseColor)?.copy({ opacity: 0.7 })?.toString() || baseColor; + } else { + // Article node + const cat = d.parent?.data.name || 'Uncategorized'; + const baseColor = PREDEFINED_CATEGORY_COLORS[cat] || ordinal(cat); + const maxVal = categoryMax[cat] || 1; + context.fillStyle = getShade(baseColor, d.data[sizeMetric] || 0, maxVal); + } + + context.strokeStyle = '#fff'; + context.lineWidth = 1; + context.beginPath(); + context.rect(x, y, w, h); + context.fill(); + context.stroke(); + + if (hoveredNode === d) { + context.save(); + const lighterFill = + d3.color(context.fillStyle)?.brighter(0.8).formatHex() || context.fillStyle; + context.fillStyle = lighterFill; + context.fillRect(x, y, w, h); + context.restore(); + } + + // Draw text based on available space + if (d.children) { + // Category nodes + if (w > 50 && h > 25) { + context.font = 'bold 14px "Rubik"'; + context.textBaseline = 'top'; + context.fillStyle = '#fff'; + + let catText = d.data.name || ''; + while (context.measureText(catText).width > w - 10 && catText.length > 1) { + catText = catText.slice(0, -1); + } + if (catText !== d.data.name) catText += '...'; + context.fillText(catText, x + 5, y + 5); + } + } else { + // Article nodes - size-based rendering + if (w > 80 && h > 45) { + // Large articles - show title and value + context.font = '12px "Rubik"'; + context.textBaseline = 'top'; + context.fillStyle = '#fff'; + + let text = d.data.name || ''; + while (context.measureText(text).width > w - 10 && text.length > 1) { + text = text.slice(0, -1); + } + if (text !== d.data.name) text += '...'; + context.fillText(text, x + 5, y + 5); + + context.font = '10px "Rubik"'; + const value = d.data[sizeMetric]; + const valueText = + sizeMetric === 'clickRate' ? `${value}%` : value?.toLocaleString() || ''; + context.fillText(valueText, x + 5, y + 20); + } else if (w > 40 && h > 25) { + // Medium articles - just show title + context.font = '11px "Rubik"'; + context.textBaseline = 'top'; + context.fillStyle = '#fff'; + + let text = d.data.name || ''; + while (context.measureText(text).width > w - 8 && text.length > 1) { + text = text.slice(0, -1); + } + if (text !== d.data.name) text += '...'; + context.fillText(text, x + 4, y + 4); + } else if (w > 20 && h > 15) { + // Small articles - just show a truncated title + context.font = '9px "Rubik"'; + context.textBaseline = 'top'; + context.fillStyle = '#fff'; + + let text = d.data.name?.substring(0, 5) || ''; + if (text.length < d.data.name?.length) text += '...'; + context.fillText(text, x + 3, y + 3); + } else if (w > 10 && h > 10) { + // Tiny articles - just show a dot + context.fillStyle = '#fff'; + context.beginPath(); + context.arc(x + w / 2, y + h / 2, 2, 0, 2 * Math.PI); + context.fill(); + } + // For very tiny articles, just the colored rectangle is visible + } + }); + + context.restore(); + }; + + // Handle semantic zooming + const handleZoom = (event: d3.D3ZoomEvent) => { + if (isTransitioning) return; + + // Throttle zoom events to reduce frequency + if (!throttleZoom()) return; + setIsTransitioning(true); + + const canvas = canvasRef.current; + if (!canvas) { + setIsTransitioning(false); + return; + } + + // Get mouse position in canvas coordinates + const [pointerX, pointerY] = d3.pointer(event, canvas); + + // Calculate the new scale and transform + const newScale = event.transform.k; + + // Update the zoom store + setZoomLevel(newScale); + + // Calculate visible region in the original treemap coordinates + const visibleWidth = dimensions.width / newScale; + const visibleHeight = dimensions.height / newScale; + + // Center on mouse position + const visibleX = pointerX - visibleWidth / 2; + const visibleY = pointerY - visibleHeight / 2; + + // Update zoom state + setZoomState({ + scale: newScale, + centerX: pointerX, + centerY: pointerY, + visibleRegion: { + x: visibleX, + y: visibleY, + width: visibleWidth, + height: visibleHeight, + }, + }); + + setTimeout(() => setIsTransitioning(false), 300); + }; + + const throttleZoom = (() => { + let lastCall = 0; + const threshold = 25; // ms between zoom updates + return () => { + const now = Date.now(); + if (now - lastCall < threshold) return false; + lastCall = now; + return true; + }; + })(); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const zoom = d3 + .zoom() + .scaleExtent([1, 8]) + .wheelDelta((event) => { + // Reduce scroll sensitivity + return -event.deltaY * 0.004; + }) + .filter((event) => { + // IMPORTANT: Allow pinch events by removing the filter on ctrlKey + // This was blocking pinch gestures on touch devices + return !(event.button && event.type === 'mousedown'); + }) + .interpolate(d3.interpolateZoom) + .on('zoom', function (event) { + // Round scale to nearest 0.25 increment + const k = Math.round(event.transform.k * 4) / 4; + const transform = d3.zoomIdentity.scale(k); + handleZoom({ ...event, transform }); + }); + + zoomBehaviorRef.current = zoom; + + d3.select(canvas) + .call(zoom) + .on('dblclick.zoom', null) + // Use touchstart with { passive: false } to prevent scrolling + // when performing touch gestures + .on( + 'touchstart', + function (event) { + event.preventDefault(); + }, + { passive: false } + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dimensions, isTransitioning]); + + // Sync d3 zoom when zoomLevel changes from store (e.g., slider) + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !zoomBehaviorRef.current) return; + + // Only update if the zoom level from store differs from current zoom state + if (Math.abs(zoomLevel - zoomState.scale) > 0.01) { + // Calculate visible region centered on the canvas + const centerX = dimensions.width / 2; + const centerY = dimensions.height / 2; + const visibleWidth = dimensions.width / zoomLevel; + const visibleHeight = dimensions.height / zoomLevel; + const visibleX = centerX - visibleWidth / 2; + const visibleY = centerY - visibleHeight / 2; + + // Update zoom state directly (without triggering d3 zoom event) + setZoomState({ + scale: zoomLevel, + centerX, + centerY, + visibleRegion: { + x: visibleX, + y: visibleY, + width: visibleWidth, + height: visibleHeight, + }, + }); + + // Update d3 transform without triggering zoom event + const transform = d3.zoomIdentity.scale(zoomLevel); + d3.select(canvas).property('__zoom', transform); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomLevel, dimensions.width, dimensions.height]); + + // Find node at position + const findNodeAtPosition = (x: number, y: number) => { + for (const node of treemapNodesRef.current) { + if (x >= node.x0 && x <= node.x1 && y >= node.y0 && y <= node.y1) { + if (!node.children) { + return node; + } + } + } + return null; + }; + + // Add mouse event listeners + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleMouseMove = (event: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const hit = findNodeAtPosition(mouseX, mouseY); + setHoveredNode(hit); + + // Set cursor style + canvas.style.cursor = hit ? 'pointer' : 'default'; + + if (hit && tooltipRef.current) { + tooltipRef.current.style.opacity = '1'; + tooltipRef.current.style.left = `${event.pageX + 10}px`; + tooltipRef.current.style.top = `${event.pageY + 10}px`; + tooltipRef.current.innerHTML = ` +
${hit.data.name}
+
+ Total Clicks: + ${hit.data.clicks?.toLocaleString() || ''} +
+
+ Unique Clicks: + ${hit.data.uniqueClicks?.toLocaleString() || ''} +
+
+ Click Rate: + ${hit.data.clickRate ? hit.data.clickRate + '%' : ''} +
+ `; + } else if (tooltipRef.current) { + tooltipRef.current.style.opacity = '0'; + } + }; + + const handleClick = (event: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const hit = findNodeAtPosition(mouseX, mouseY); + if (hit && !hit.children && hit.data.slug) { + setIsNavigating(true); + setClickedArticle(hit.data.name); + window.gtag('event', 'article_click', { + event_category: hit.parent?.data.name || 'Uncategorized', + event_label: hit.data.name, + event_link: `/posts/${hit.data.slug}`, + transport_type: 'beacon', + }); + router.push(`/posts/${hit.data.slug}`); + } + }; + + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('click', handleClick); + return () => { + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('click', handleClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Re-draw when hovered node changes + useEffect(() => { + draw(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hoveredNode]); + + return ( +
+ setIsSidebarOpen(!isSidebarOpen)} + activeView={activeView} + onViewChange={onViewChange} + /> + +
+ {/* Loading overlay */} + {isNavigating && ( +
+
+
+
+

Loading Article

+ {clickedArticle && ( +

+ {clickedArticle} +

+ )} +
+
+
+ )} + + {/* SEO and accessibility block */} +
+ {postsWithMetrics.map((post) => ( +
+

{post.title.replace(/\n\s*/g, ' ')}

+

{post.shortDescription ?? ''}

+

{post.meta?.description ?? ''}

+

+ {( + post.content?.root?.children?.[0]?.children?.[0] as { + text: string; + } + )?.text ?? ''} +

+
    + {post.category_titles?.map((category) => ( +
  • {category}
  • + ))} +
+ {post.slug && Read more} +
+ ))} +
+ + {/* The interactive treemap */} +
+ +
+
+
+
+ ); +}; diff --git a/src/components/ArticleAnalyser/CardMobileView.tsx b/src/components/ArticleAnalyser/CardMobileView.tsx new file mode 100644 index 0000000..f9d67f2 --- /dev/null +++ b/src/components/ArticleAnalyser/CardMobileView.tsx @@ -0,0 +1,128 @@ +import React, { FC } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Post } from '@/payload-types'; +import { CategoryPill } from '@/components/CateoryPill'; +import { SearchInput } from './SearchInput'; +import { useCategories, useArticleNavigation } from './hooks'; +import { getImageUrl, getMockStats } from './utils'; +import { useFilterStore, useFilteredPosts } from '@/stores/useFilterStore'; + +interface CardMobileViewProps { + posts: Post[]; +} + +export const CardMobileView: FC = ({ posts }) => { + // Use Zustand store for filter state and filtered posts + const { selectedCategories, searchQuery, toggleCategory, setSearchQuery } = useFilterStore(); + + const mockStats = getMockStats(); + const categories = useCategories(posts); + const filteredPosts = useFilteredPosts(posts); + const { loadingSlug, handleArticleClick } = useArticleNavigation(); + + return ( +
+ {/* Stats Ticker */} +
+
+ + GRID OUTPUT: + {mockStats.gridOutput} +
+
+ + AQI: + + {mockStats.aqi} ({mockStats.aqiStatus}) + +
+
+ + NEW NODES: + {mockStats.newNodes} +
+
+ + {/* Search Bar */} +
+ +
+ + {/* Category Filter Tags */} +
+
+ {categories.map((category) => ( + + ))} +
+
+ +
+ {filteredPosts.map((post) => { + const imageUrl = getImageUrl(post); + return ( + handleArticleClick(post, e)} + className="group relative overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800" + > + {loadingSlug === post.slug && ( +
+
+
+ )} + + {/* Card Image */} +
+ {post.title} +
+ + {/* Category Badges */} + {post.category_titles && post.category_titles.length > 0 && ( +
+ {post.category_titles.map((category) => ( + + ))} +
+ )} +
+ + {/* Card Content */} +
+ {/* Title */} +

+ {post.title.replace(/\n\s*/g, ' ')} +

+ + {/* Description */} + {post.shortDescription && ( +

{post.shortDescription}

+ )} +
+ + ); + })} +
+
+ ); +}; diff --git a/src/components/ArticleAnalyser/DesktopGridView.tsx b/src/components/ArticleAnalyser/DesktopGridView.tsx new file mode 100644 index 0000000..66e44a2 --- /dev/null +++ b/src/components/ArticleAnalyser/DesktopGridView.tsx @@ -0,0 +1,127 @@ +import React, { FC, useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Post } from '@/payload-types'; +import { CategoryPill } from '@/components/CateoryPill'; +import { useArticleNavigation } from './hooks'; +import { getImageUrl } from './utils'; +import { ViewType } from './types'; +import { useFilterStore, useFilteredPosts } from '@/stores/useFilterStore'; +import { FilterSidebar } from './FilterSidebar'; +import { Hero } from '@/components/Header'; +import { cn } from '@/utilities/ui'; + +interface DesktopGridViewProps { + posts: Post[]; + activeView?: ViewType; + onViewChange?: (view: ViewType) => void; +} + +export const DesktopGridView: FC = ({ posts, activeView, onViewChange }) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + // Use Zustand store for all filter state and filtered posts + const { selectedCategories, toggleCategory } = useFilterStore(); + const filteredPosts = useFilteredPosts(posts); + const { loadingSlug, handleArticleClick } = useArticleNavigation(); + + return ( +
+ setIsSidebarOpen(!isSidebarOpen)} + activeView={activeView} + onViewChange={onViewChange} + /> + + {/* Main Content Area - Add left margin when sidebar is open */} +
+ {/* Hero Section */} + console.log('New idea essay clicked')} /> + + {/* Category Filter Pills - show selected categories */} + {selectedCategories.length > 0 && ( +
+
+ {selectedCategories.map((category) => ( + + ))} +
+
+ )} + + {/* Article Grid */} +
+
+ {filteredPosts.map((post) => { + const imageUrl = getImageUrl(post); + return ( + handleArticleClick(post, e)} + className="group relative flex flex-col overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800 hover:shadow-xl" + > + {loadingSlug === post.slug && ( +
+
+
+ )} + + {/* Card Image */} +
+ {post.title} +
+ + {/* Category Badges */} + {post.category_titles && post.category_titles.length > 0 && ( +
+ {post.category_titles.map((category) => ( + + ))} +
+ )} +
+ + {/* Card Content */} +
+ {/* Title */} +

+ {post.title.replace(/\n\s*/g, ' ')} +

+ + {/* Description */} + {post.shortDescription && ( +

+ {post.shortDescription} +

+ )} +
+ + ); + })} +
+
+
+
+ ); +}; diff --git a/src/components/ArticleAnalyser/FilterSidebar.tsx b/src/components/ArticleAnalyser/FilterSidebar.tsx new file mode 100644 index 0000000..46f1fb6 --- /dev/null +++ b/src/components/ArticleAnalyser/FilterSidebar.tsx @@ -0,0 +1,190 @@ +import React, { FC } from 'react'; +import { cn } from '@/utilities/ui'; +import { Post } from '@/payload-types'; +import { ViewToggle } from './ViewToggle'; +import { ViewType } from './types'; +import { SliderFilter } from '@/components/SliderFilter'; +import { SearchInput } from './SearchInput'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/base/accordion'; +import { Checkbox } from '@/base/checkbox'; +import { Label } from '@/base/label'; +import { useFilterStore, TimeFilter } from '@/stores/useFilterStore'; +import { useZoomStore } from '@/stores/useZoomStore'; +import { useCategories } from './hooks'; + +interface FilterSidebarProps { + posts: Post[]; + isOpen: boolean; + onToggle: () => void; + activeView?: ViewType; + onViewChange?: (view: ViewType) => void; +} + +export const FilterSidebar: FC = ({ + posts, + isOpen, + onToggle, + activeView, + onViewChange, +}) => { + const { + selectedCategories, + timeFilter, + searchQuery, + contentBalance, + toggleCategory, + setTimeFilter, + setSearchQuery, + setContentBalance, + } = useFilterStore(); + const { zoomLevel, setZoomLevel } = useZoomStore(); + const categories = useCategories(posts); + + return ( + <> + {/* Collapsible Sidebar - Fixed position */} +
+ {isOpen && ( +
+
+ +
+ + {/* View Toggle */} + {activeView && onViewChange && ( +
+

+ View Mode +

+ +
+ )} + + {/* Slider Filter */} +
+ {activeView === 'wba' ? ( + <> +

+ Zoom Level +

+ setZoomLevel(value[0])} + /> + + ) : ( + <> +

+ Content Balance +

+ setContentBalance(value[0])} + /> + + )} +
+ + {/* Time Filter */} +
+

+ Time Period +

+
+ {[ + { value: 'all', label: 'All' }, + { value: 'today', label: 'Today' }, + { value: 'week', label: 'This Week' }, + { value: 'month', label: 'This Month' }, + ].map((option) => ( + + ))} +
+
+ + {/* Category Filter Accordion */} +
+ + + + Categories ({selectedCategories.length} selected) + + +
+ {categories.map((category) => ( +
+ toggleCategory(category)} + className="border-neutral-700 bg-neutral-900 data-[state=checked]:border-cyan-500 data-[state=checked]:bg-cyan-500" + /> + +
+ ))} +
+
+
+
+
+
+ )} +
+ + {/* Toggle Sidebar Button */} + + + ); +}; diff --git a/src/components/ArticleAnalyser/SearchInput.tsx b/src/components/ArticleAnalyser/SearchInput.tsx new file mode 100644 index 0000000..a43ffcd --- /dev/null +++ b/src/components/ArticleAnalyser/SearchInput.tsx @@ -0,0 +1,101 @@ +import React, { FC } from 'react'; +import { SearchVariant } from './types'; + +const searchVariantStyles: Record< + SearchVariant, + { container: string; icon: string; input: string; filterBtn: string } +> = { + light: { + container: 'relative', + icon: 'h-5 w-5 text-neutral-400', + input: + 'w-full rounded-lg border-2 border-neutral-300 bg-neutral-white py-2.5 pl-10 pr-4 text-base text-neutral-900 transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500 placeholder:text-neutral-400', + filterBtn: 'hidden', + }, + dark: { + container: 'flex gap-3 rounded-xl px-4 py-3 bg-border bg-input', + icon: 'h-5 w-5 text-neutral-500', + input: + 'flex-1 bg-transparent text-sm text-neutral-white placeholder:text-neutral-300 focus:outline-none', + filterBtn: 'rounded-lg bg-[#2d333b] p-2 hover:bg-[#3d434b] transition-colors', + }, +}; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + variant?: SearchVariant; + placeholder?: string; +} + +export const SearchInput: FC = ({ + value, + onChange, + variant = 'light', + placeholder = 'Search articles by title...', +}) => { + const styles = searchVariantStyles[variant]; + + if (variant === 'dark') { + return ( +
+ + onChange(e.target.value)} + className={styles.input} + aria-label={placeholder} + /> +
+ ); + } + + // Light variant (original style) + return ( +
+ +
+ +
+ onChange(e.target.value)} + className={styles.input} + aria-label={placeholder} + /> +
+ ); +}; diff --git a/src/components/ArticleAnalyser/ViewToggle.tsx b/src/components/ArticleAnalyser/ViewToggle.tsx new file mode 100644 index 0000000..77a4603 --- /dev/null +++ b/src/components/ArticleAnalyser/ViewToggle.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react'; +import { cn } from '@/utilities/ui'; +import { ViewType } from './types'; + +interface ViewToggleProps { + activeView: ViewType; + onViewChange: (view: ViewType) => void; +} + +export const ViewToggle: FC = ({ activeView, onViewChange }) => { + return ( +
+ + +
+ ); +}; diff --git a/src/components/ArticleAnalyser/WBAMobileView.tsx b/src/components/ArticleAnalyser/WBAMobileView.tsx new file mode 100644 index 0000000..13d5fd2 --- /dev/null +++ b/src/components/ArticleAnalyser/WBAMobileView.tsx @@ -0,0 +1,95 @@ +import React, { FC, useMemo } from 'react'; +import Link from 'next/link'; +import { Post } from '@/payload-types'; +import { PostWithMetrics } from './types'; +import { getShade, getCategoryColor as getColor, enrichPostsWithMetrics } from './utils'; +import { useArticleNavigation } from './hooks'; + +interface WBAMobileViewProps { + posts: Post[]; +} + +export const WBAMobileView: FC = ({ posts }) => { + const { loadingSlug, handleArticleClick } = useArticleNavigation(); + + const postsWithMetrics = useMemo(() => enrichPostsWithMetrics(posts), [posts]); + + // Group posts by category and sort by clicks + const categorizedPosts = useMemo(() => { + const categorized: Record = {}; + postsWithMetrics.forEach((post) => { + const category = + post.category_titles && post.category_titles.length > 0 + ? post.category_titles[0] + : 'Uncategorized'; + if (!categorized[category]) categorized[category] = []; + categorized[category].push(post); + }); + // Sort articles within each category by clicks (descending) + Object.keys(categorized).forEach((category) => { + categorized[category].sort((a, b) => b.clicks - a.clicks); + }); + return categorized; + }, [postsWithMetrics]); + + const allCategories = useMemo(() => Object.keys(categorizedPosts), [categorizedPosts]); + + const getCategoryColor = (category: string) => getColor(category, allCategories); + + // Calculate max clicks per category for color gradient + const categoryMaxClicks = useMemo(() => { + const maxClicks: Record = {}; + Object.entries(categorizedPosts).forEach(([category, posts]) => { + maxClicks[category] = Math.max(...posts.map((p) => p.clicks)); + }); + return maxClicks; + }, [categorizedPosts]); + + // Get shade color for article based on clicks + const getArticleColor = (category: string, clicks: number) => { + const baseColor = getCategoryColor(category); + const maxClicks = categoryMaxClicks[category] || 1; + return getShade(baseColor, clicks, maxClicks); + }; + + return ( +
+ {Object.entries(categorizedPosts).map(([category, categoryPosts]) => ( +
+ {/* Category Header */} +
+ {category} ({categoryPosts.length}) +
+ + {/* Articles in this category */} +
+ {categoryPosts.map((post) => ( + handleArticleClick(post, e)} + > + {loadingSlug === post.slug && ( +
+
+
+ )} + + {post.title.replace(/\n\s*/g, ' ')} + + + {post.clicks?.toLocaleString()} clicks + + + ))} +
+
+ ))} +
+ ); +}; diff --git a/src/components/ArticleAnalyser/hooks.ts b/src/components/ArticleAnalyser/hooks.ts new file mode 100644 index 0000000..386e8a7 --- /dev/null +++ b/src/components/ArticleAnalyser/hooks.ts @@ -0,0 +1,42 @@ +import { useState, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { Post } from '@/payload-types'; + +/** + * Hook for getting unique categories from posts + */ +export const useCategories = (posts: Post[]) => { + return useMemo(() => { + const cats = new Set(); + posts.forEach((post) => { + if (post.category_titles && post.category_titles.length > 0) { + post.category_titles.forEach((cat) => cats.add(cat)); + } + }); + return Array.from(cats).sort(); + }, [posts]); +}; + +/** + * Hook for handling article navigation with loading state + */ +export const useArticleNavigation = () => { + const router = useRouter(); + const [loadingSlug, setLoadingSlug] = useState(null); + + const handleArticleClick = (post: Post, e: React.MouseEvent) => { + if (post.slug) { + e.preventDefault(); + setLoadingSlug(post.slug); + window.gtag?.('event', 'article_click', { + event_category: post.category_titles?.[0] || 'Uncategorized', + event_label: post.title, + event_link: `/posts/${post.slug}`, + transport_type: 'beacon', + }); + router.push(`/posts/${post.slug}`); + } + }; + + return { loadingSlug, handleArticleClick }; +}; diff --git a/src/components/ArticleAnalyser/index.tsx b/src/components/ArticleAnalyser/index.tsx index df59070..61e330e 100644 --- a/src/components/ArticleAnalyser/index.tsx +++ b/src/components/ArticleAnalyser/index.tsx @@ -1,1486 +1,60 @@ 'use client'; -import React, { useState, useEffect, useMemo, useRef, FC } from 'react'; -import * as d3 from 'd3'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; - -import { Post, Media as MediaType } from '@/payload-types'; -import { PostWithMetrics, HierarchyNode, SizeMetric } from './types'; -import { useCanvasStore } from '@/store/useCanvasStore'; -import { cn } from '@/utilities/ui'; -// Media component available if needed for future use -import { getClientSideURL } from '@/utilities/getURL'; -import { CategoryPill } from '../CateoryPill'; - -const headerHeight = 60; - -type MobileViewType = 'default' | 'wba'; -const TARGET_NODE_COUNT = 40; // Target number of articles to display at any zoom level - -// Simple seeded random number generator for consistent SSR/client results -const seededRandom = (seed: number) => { - const x = Math.sin(seed) * 10000; - return x - Math.floor(x); -}; - -/** - * A helper function that returns a shade of the base color. - * The variation controls how much we brighten/darken. - */ -function getShade(baseColor: string, value: number, max: number, variation: number = 2): string { - const t = max > 0 ? value / max : 0; - const brighter = d3.color(baseColor)?.brighter(variation).formatHex() || baseColor; - const darker = d3.color(baseColor)?.darker(variation).formatHex() || baseColor; - return d3.interpolateLab(brighter, darker)(t); +import React, { FC } from 'react'; +import { Post } from '@/payload-types'; +import { DesktopGridView } from './DesktopGridView'; +import { CardMobileView } from './CardMobileView'; +import { WBAMobileView } from './WBAMobileView'; +import { ArticleTreeMap } from './ArticleTreeMap'; +import { ViewToggle } from './ViewToggle'; +import { Header } from '@/components/Header'; +import { useViewStore } from '@/stores/useViewStore'; + +interface ArticleAnalyserProps { + posts: Post[]; } -/*–––––––––––––––––––––––––––––––––––––– - SEARCH INPUT COMPONENT -–––––––––––––––––––––––––––––––––––––––––*/ - -/** - * A simple search input that accepts text and calls onChange. - * Supports 'light' and 'dark' variants for different themes. - */ -type SearchVariant = 'light' | 'dark'; - -const searchVariantStyles: Record< - SearchVariant, - { container: string; icon: string; input: string; filterBtn: string } -> = { - light: { - container: 'relative', - icon: 'h-5 w-5 text-neutral-400', - input: - 'w-full rounded-lg border-2 border-neutral-300 bg-neutral-white py-2.5 pl-10 pr-4 text-base text-neutral-900 transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500 placeholder:text-neutral-400', - filterBtn: 'hidden', - }, - dark: { - container: 'flex items-center gap-3 rounded-xl px-4 py-3 bg-border bg-input', - icon: 'h-5 w-5 text-neutral-500', - input: - 'flex-1 bg-transparent text-sm text-neutral-white placeholder:text-neutral-300 focus:outline-none', - filterBtn: 'rounded-lg bg-[#2d333b] p-2 hover:bg-[#3d434b] transition-colors', - }, -}; - -const SearchInput: FC<{ - value: string; - onChange: (value: string) => void; - variant?: SearchVariant; - placeholder?: string; -}> = ({ value, onChange, variant = 'light', placeholder = 'Search articles by title...' }) => { - const styles = searchVariantStyles[variant]; - - if (variant === 'dark') { - return ( -
- - onChange(e.target.value)} - className={styles.input} - aria-label={placeholder} - /> - {/* */} -
- ); - } - - // Light variant (original style) - return ( -
- -
- -
- onChange(e.target.value)} - className={styles.input} - aria-label={placeholder} - /> -
- ); -}; - -/*–––––––––––––––––––––––––––––––––––––– - VIEW TOGGLE COMPONENT -–––––––––––––––––––––––––––––––––––––––––*/ - -const ViewToggle: FC<{ - activeView: MobileViewType; - onViewChange: (view: MobileViewType) => void; -}> = ({ activeView, onViewChange }) => { - return ( -
- - -
- ); -}; - -/*–––––––––––––––––––––––––––––––––––––– - NEW CARD-BASED MOBILE VIEW (Default) -–––––––––––––––––––––––––––––––––––––––––*/ - -const CardMobileView: FC<{ posts: Post[] }> = ({ posts }) => { - const router = useRouter(); - const [loadingSlug, setLoadingSlug] = useState(null); - const [selectedCategory, setSelectedCategory] = useState('All Feeds'); - const [searchQuery, setSearchQuery] = useState(''); - - // Mock data for stats ticker - const mockStats = { - gridOutput: '+12%', - aqi: 42, - aqiStatus: 'GOOD', - newNodes: 8, - }; - - // Get unique categories from posts - const categories = useMemo(() => { - const cats = new Set(); - posts.forEach((post) => { - if (post.category_titles && post.category_titles.length > 0) { - post.category_titles.forEach((cat) => cats.add(cat)); - } - }); - return ['All Feeds', ...Array.from(cats)]; - }, [posts]); - - // Filter posts by category and search query - const filteredPosts = useMemo(() => { - let result = posts; - - // Filter by category - if (selectedCategory !== 'All Feeds') { - result = result.filter( - (post) => post.category_titles && post.category_titles.includes(selectedCategory) - ); - } - - // Filter by search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (post) => - post.title.toLowerCase().includes(query) || - post.shortDescription?.toLowerCase().includes(query) - ); - } - - return result; - }, [posts, selectedCategory, searchQuery]); - - const handleArticleClick = (post: Post, e: React.MouseEvent) => { - if (post.slug) { - e.preventDefault(); - setLoadingSlug(post.slug); - window.gtag?.('event', 'article_click', { - event_category: post.category_titles?.[0] || 'Uncategorized', - event_label: post.title, - event_link: `/posts/${post.slug}`, - transport_type: 'beacon', - }); - router.push(`/posts/${post.slug}`); - } - }; +export const ArticleAnalyser: FC = ({ posts }) => { + // Use persisted view store + const { view, setView } = useViewStore(); - const getImageUrl = (post: Post): string | null => { - if (post.heroImage && typeof post.heroImage === 'object') { - const media = post.heroImage as MediaType; - if (media.url) { - return `${getClientSideURL()}${media.url}`; - } - } - // Use placeholder if no hero image - return '/images/future1.webp'; - }; + // Note: Filtering is now handled by individual view components using useFilterStore + // This keeps the code DRY and centralized in the Zustand store return (
- {/* Stats Ticker */} -
-
- - GRID OUTPUT: - {mockStats.gridOutput} -
-
- - AQI: - - {mockStats.aqi} ({mockStats.aqiStatus}) - -
-
- - NEW NODES: - {mockStats.newNodes} -
-
- - {/* Search Bar */} -
- -
+ {/* Header - Fixed at top */} +
- {/* Hero Section - Global Sentiment Heatmap */} - {/*
-
- Global Sentiment Heatmap -
- -
- - Live Data - -
- - + {/* Content with padding for fixed header */} +
+
+
-
-

Global Sentiment Heatmap

-

- Visualizing ecological restoration progress... -

-
-
-
+ {/* Mobile view with toggle - shown on screens < 768px */} +
+ {view === 'default' ? : }
-
*/} - {/* Category Filter Tags - Fixed visibility */} -
-
- {categories.map((category) => ( - - ))} + {/* Desktop view with toggle - shown on screens >= 768px */} +
+ {view === 'default' ? ( + + ) : ( + + )}
- - {/* Latest Transmissions Header */} -
-

Latest Transmissions

-
- - {/* Article Cards */} -
- {filteredPosts.map((post) => { - const imageUrl = getImageUrl(post); - return ( - handleArticleClick(post, e)} - className="group relative overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800" - > - {loadingSlug === post.slug && ( -
-
-
- )} - - {/* Card Image */} -
- {post.title} -
- - {/* Category Badges - positioned 10px from bottom of image */} - {post.category_titles && post.category_titles.length > 0 && ( -
- {post.category_titles.map((category) => ( - - ))} -
- )} -
- - {/* Card Content */} -
- {/* Title */} -

- {post.title.replace(/\n\s*/g, ' ')} -

- - {/* Description */} - {post.shortDescription && ( -

{post.shortDescription}

- )} -
- - ); - })} -
-
- ); -}; - -/*–––––––––––––––––––––––––––––––––––––– - ARTICLE ANALYZER – TREEMAP (Snippet 2) -–––––––––––––––––––––––––––––––––––––––––*/ - -export const ArticleTreeMap: FC<{ posts: Post[] }> = ({ posts }) => { - const router = useRouter(); - const canvasRef = useRef(null); - const tooltipRef = useRef(null); - const [sizeMetric] = useState('clicks'); - const [postsWithMetrics, setPostsWithMetrics] = useState([]); - const [isNavigating, setIsNavigating] = useState(false); - const [clickedArticle, setClickedArticle] = useState(null); - - // Store the current zoom state - const [zoomState, setZoomState] = useState({ - scale: 1, - centerX: 0, - centerY: 0, - visibleRegion: { x: 0, y: 0, width: 0, height: 0 }, - }); - - // Cache the treemap nodes after layout computation - const treemapNodesRef = useRef[]>([]); - const allNodesRef = useRef[]>([]); - - // For hover interactions - const [hoveredNode, setHoveredNode] = useState | null>( - null - ); - const { dimensions, setDimensions } = useCanvasStore(); - const [isTransitioning, setIsTransitioning] = useState(false); - - // Track the full hierarchy data - const hierarchyRootRef = useRef | null>(null); - - // Set up canvas dimensions - useEffect(() => { - const handleResize = () => { - setDimensions({ - width: window.innerWidth, - height: window.innerHeight - headerHeight, - }); - }; - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [setDimensions]); - - // Enrich posts with deterministic metrics (consistent across SSR and client) - useEffect(() => { - const enriched = posts.map((post) => { - // Use post ID as seed for consistent random values - const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0; - const clicks = Math.floor(seededRandom(seed) * 2000) + 100; - const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2)); - const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1); - return { ...post, clicks, uniqueClicks, clickRate }; - }); - setPostsWithMetrics(enriched); - }, [posts]); - - // Build hierarchical data for the treemap - const hierarchyData: HierarchyNode = useMemo(() => { - const categorized: Record = {}; - postsWithMetrics.forEach((post) => { - const category = - post.category_titles && post.category_titles.length > 0 - ? post.category_titles[0] - : 'Uncategorized'; - if (!categorized[category]) categorized[category] = []; - categorized[category].push(post); - }); - return { - name: 'Articles', - children: Object.keys(categorized).map((category) => ({ - name: category, - children: categorized[category].map((post) => ({ - name: post.title.replace(/\n\s*/g, ' '), - clicks: post.clicks, - uniqueClicks: post.uniqueClicks, - clickRate: post.clickRate, - slug: post.slug, - id: post.id, - })), - })), - }; - }, [postsWithMetrics]); - - // Limit articles to target density - const limitArticlesToTargetDensity = ( - nodes: d3.HierarchyRectangularNode[], - targetCount: number, - zoomScale: number - ) => { - // If we have fewer nodes than target, return all of them - if (nodes.length <= targetCount) return nodes; - - // Filter for leaf nodes (articles) and category nodes - const categoryNodes = nodes.filter((n) => n.depth === 1); - const leafNodes = nodes.filter((n) => !n.children && n.depth > 1); - - if (leafNodes.length <= targetCount) { - // If we have fewer leaf nodes than target, return all categories and leaves - return [...categoryNodes, ...leafNodes]; - } - - // When highly zoomed in, prioritize visible articles with higher values - if (zoomScale > 2) { - const sortedLeaves = [...leafNodes].sort((a, b) => (b.value || 0) - (a.value || 0)); - return [...categoryNodes, ...sortedLeaves.slice(0, targetCount - categoryNodes.length)]; - } - - // When moderately zoomed, group smaller articles by their parent category - const topLeaves = leafNodes - .sort((a, b) => (b.value || 0) - (a.value || 0)) - .slice(0, Math.floor(targetCount * 0.7)); - - // The rest can be represented as aggregated nodes - const remainingLeaves = leafNodes - .sort((a, b) => (b.value || 0) - (a.value || 0)) - .slice(Math.floor(targetCount * 0.7)); - - // Group by parent category - const groupedByCat: Record[]> = {}; - remainingLeaves.forEach((node) => { - const category = node.parent?.data.name || 'Uncategorized'; - if (!groupedByCat[category]) groupedByCat[category] = []; - groupedByCat[category].push(node); - }); - - // Return the categories, top leaves, and grouped nodes - return [...categoryNodes, ...topLeaves]; - }; - - // Calculate which nodes to display based on visible region - const getNodesInVisibleRegion = (zoomState: { - scale: number; - centerX: number; - centerY: number; - visibleRegion: { x: number; y: number; width: number; height: number }; - }) => { - if (!allNodesRef.current.length) return []; - - const { x, y, width, height } = zoomState.visibleRegion; - - // Find nodes that intersect with the visible region - const visibleNodes = allNodesRef.current.filter((node) => { - return node.x0 < x + width && node.x1 > x && node.y0 < y + height && node.y1 > y; - }); - - // Limit the number of nodes to a reasonable density - return limitArticlesToTargetDensity(visibleNodes, TARGET_NODE_COUNT, zoomState.scale); - }; - - // Compute treemap layout for visible nodes - const computeVisibleTreemap = (visibleNodes: d3.HierarchyRectangularNode[]) => { - if (!visibleNodes.length) return; - - // Get category nodes and leaf nodes - const categoryNodes = visibleNodes.filter((n) => n.depth === 1); - const leafNodes = visibleNodes.filter((n) => !n.children && n.depth > 1); - - // Create a new temporary hierarchy for layout - const tempHierarchy: HierarchyNode = { - name: 'Visible Articles', - children: categoryNodes - .map((catNode) => ({ - name: catNode.data.name, - children: leafNodes - .filter((leaf) => leaf.parent?.data.name === catNode.data.name) - .map((leaf) => leaf.data), - })) - .filter((cat) => cat.children && cat.children.length > 0), - }; - - // Create a new hierarchy and compute the treemap - const root = d3 - .hierarchy(tempHierarchy) - .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0)) - .sort((a, b) => (b.value || 0) - (a.value || 0)); - - const treemapLayout = d3 - .treemap() - .size([dimensions.width, dimensions.height]) - .paddingOuter(3) - .paddingTop(22) - .paddingInner(1) - .round(true); - - treemapLayout(root); - - // Store the new layout - treemapNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[]; - }; - - // Initial computation of the full treemap - useEffect(() => { - if (!hierarchyData.children || hierarchyData.children.length === 0) return; - - // Create the full hierarchy - const root = d3 - .hierarchy(hierarchyData) - .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0)) - .sort((a, b) => (b.value || 0) - (a.value || 0)); - - hierarchyRootRef.current = root; - - // Compute the initial treemap for all nodes - const treemapLayout = d3 - .treemap() - .size([dimensions.width, dimensions.height]) - .paddingOuter(3) - .paddingTop(22) - .paddingInner(1) - .round(true); - - treemapLayout(root); - - // Store all nodes for reference - allNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[]; - treemapNodesRef.current = allNodesRef.current; - - // Update visible region based on dimensions - setZoomState((prev) => ({ - ...prev, - visibleRegion: { - x: 0, - y: 0, - width: dimensions.width, - height: dimensions.height, - }, - })); - - // After computing the layout, draw the treemap - draw(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hierarchyData, sizeMetric, dimensions]); - - // When zoom state changes, recalculate visible nodes - useEffect(() => { - if (allNodesRef.current.length === 0) return; - - const visibleNodes = getNodesInVisibleRegion(zoomState); - - if (zoomState.scale > 1.2) { - // When zoomed in enough, compute a new treemap layout for just the visible nodes - computeVisibleTreemap(visibleNodes); - } else { - // When at default zoom, use the full treemap - treemapNodesRef.current = allNodesRef.current; - } - - draw(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [zoomState]); - - const renderConstants = useMemo(() => { - if (!treemapNodesRef.current.length) { - return null; - } - const palette = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6']; - const predefinedColors: Record = { - AI: palette[0], - Earth: palette[1], - }; - const categoryNodes = treemapNodesRef.current.filter((d) => d.depth === 1); - const allCategories = categoryNodes.map((d) => d.data.name); - const otherCategories = allCategories.filter((cat) => !(cat in predefinedColors)); - const ordinal = d3 - .scaleOrdinal() - .domain(otherCategories) - .range(palette.slice(2)); - const categoryMax: Record = {}; - categoryNodes.forEach((catNode) => { - let maxVal = 0; - catNode.leaves().forEach((leaf) => { - maxVal = Math.max(maxVal, leaf.data[sizeMetric] || 0); - }); - categoryMax[catNode.data.name] = maxVal; - }); - return { palette, predefinedColors, categoryNodes, ordinal, categoryMax }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [treemapNodesRef.current.length, sizeMetric]); - - // Optimize the canvas setup - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const dpr = window.devicePixelRatio || 1; - canvas.width = dimensions.width * dpr; - canvas.height = dimensions.height * dpr; - canvas.style.width = `${dimensions.width}px`; - canvas.style.height = `${dimensions.height}px`; - - const context = canvas.getContext('2d'); - if (context) { - context.scale(dpr, dpr); - } - }, [dimensions]); - - // Draw the treemap - const draw = () => { - const canvas = canvasRef.current; - if (!canvas) return; - - const context = canvas.getContext('2d'); - if (!context) return; - - context.save(); - context.clearRect(0, 0, canvas.width, canvas.height); - - const rc = renderConstants; - if (!rc) { - context.restore(); - return; - } - const { predefinedColors, ordinal, categoryMax } = rc; - - treemapNodesRef.current.forEach((d) => { - const x = d.x0, - y = d.y0, - w = d.x1 - d.x0, - h = d.y1 - d.y0; - if (w <= 0 || h <= 0) return; - - // Use different fills based on node type - if (d.children) { - // Category node with a colored background - const categoryName = d.data.name; - const baseColor = predefinedColors[categoryName] || ordinal(categoryName) || '#333333'; - context.fillStyle = d3.color(baseColor)?.copy({ opacity: 0.7 })?.toString() || baseColor; - } else { - // Article node - const cat = d.parent?.data.name || 'Uncategorized'; - const baseColor = predefinedColors[cat] || ordinal(cat); - const maxVal = categoryMax[cat] || 1; - context.fillStyle = getShade(baseColor, d.data[sizeMetric] || 0, maxVal); - } - - context.strokeStyle = '#fff'; - context.lineWidth = 1; - context.beginPath(); - context.rect(x, y, w, h); - context.fill(); - context.stroke(); - - if (hoveredNode === d) { - context.save(); - const lighterFill = - d3.color(context.fillStyle)?.brighter(0.8).formatHex() || context.fillStyle; - context.fillStyle = lighterFill; - context.fillRect(x, y, w, h); - context.restore(); - } - - // Draw text based on available space - if (d.children) { - // Category nodes - if (w > 50 && h > 25) { - context.font = 'bold 14px "Rubik"'; - context.textBaseline = 'top'; - context.fillStyle = '#fff'; - - let catText = d.data.name || ''; - while (context.measureText(catText).width > w - 10 && catText.length > 1) { - catText = catText.slice(0, -1); - } - if (catText !== d.data.name) catText += '...'; - context.fillText(catText, x + 5, y + 5); - } - } else { - // Article nodes - size-based rendering - if (w > 80 && h > 45) { - // Large articles - show title and value - context.font = '12px "Rubik"'; - context.textBaseline = 'top'; - context.fillStyle = '#fff'; - - let text = d.data.name || ''; - while (context.measureText(text).width > w - 10 && text.length > 1) { - text = text.slice(0, -1); - } - if (text !== d.data.name) text += '...'; - context.fillText(text, x + 5, y + 5); - - context.font = '10px "Rubik"'; - const value = d.data[sizeMetric]; - const valueText = - sizeMetric === 'clickRate' ? `${value}%` : value?.toLocaleString() || ''; - context.fillText(valueText, x + 5, y + 20); - } else if (w > 40 && h > 25) { - // Medium articles - just show title - context.font = '11px "Rubik"'; - context.textBaseline = 'top'; - context.fillStyle = '#fff'; - - let text = d.data.name || ''; - while (context.measureText(text).width > w - 8 && text.length > 1) { - text = text.slice(0, -1); - } - if (text !== d.data.name) text += '...'; - context.fillText(text, x + 4, y + 4); - } else if (w > 20 && h > 15) { - // Small articles - just show a truncated title - context.font = '9px "Rubik"'; - context.textBaseline = 'top'; - context.fillStyle = '#fff'; - - let text = d.data.name?.substring(0, 5) || ''; - if (text.length < d.data.name?.length) text += '...'; - context.fillText(text, x + 3, y + 3); - } else if (w > 10 && h > 10) { - // Tiny articles - just show a dot - context.fillStyle = '#fff'; - context.beginPath(); - context.arc(x + w / 2, y + h / 2, 2, 0, 2 * Math.PI); - context.fill(); - } - // For very tiny articles, just the colored rectangle is visible - } - }); - - context.restore(); - }; - - // Handle semantic zooming - const handleZoom = (event: d3.D3ZoomEvent) => { - if (isTransitioning) return; - - // Throttle zoom events to reduce frequency - if (!throttleZoom()) return; - setIsTransitioning(true); - - const canvas = canvasRef.current; - if (!canvas) { - setIsTransitioning(false); - return; - } - - // Get mouse position in canvas coordinates - const [pointerX, pointerY] = d3.pointer(event, canvas); - - // Calculate the new scale and transform - const newScale = event.transform.k; - - // Calculate visible region in the original treemap coordinates - const visibleWidth = dimensions.width / newScale; - const visibleHeight = dimensions.height / newScale; - - // Center on mouse position - const visibleX = pointerX - visibleWidth / 2; - const visibleY = pointerY - visibleHeight / 2; - - // Update zoom state - setZoomState({ - scale: newScale, - centerX: pointerX, - centerY: pointerY, - visibleRegion: { - x: visibleX, - y: visibleY, - width: visibleWidth, - height: visibleHeight, - }, - }); - - setTimeout(() => setIsTransitioning(false), 300); - }; - - const throttleZoom = (() => { - let lastCall = 0; - const threshold = 25; // ms between zoom updates - return () => { - const now = Date.now(); - if (now - lastCall < threshold) return false; - lastCall = now; - return true; - }; - })(); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const zoom = d3 - .zoom() - .scaleExtent([1, 8]) - .wheelDelta((event) => { - // Reduce scroll sensitivity - return -event.deltaY * 0.004; - }) - .filter((event) => { - // IMPORTANT: Allow pinch events by removing the filter on ctrlKey - // This was blocking pinch gestures on touch devices - return !(event.button && event.type === 'mousedown'); - }) - .interpolate(d3.interpolateZoom) - .on('zoom', function (event) { - // Round scale to nearest 0.25 increment - const k = Math.round(event.transform.k * 4) / 4; - const transform = d3.zoomIdentity.scale(k); - handleZoom({ ...event, transform }); - }); - - d3.select(canvas) - .call(zoom) - .on('dblclick.zoom', null) - // Use touchstart with { passive: false } to prevent scrolling - // when performing touch gestures - .on( - 'touchstart', - function (event) { - event.preventDefault(); - }, - { passive: false } - ); - - // Optional: Set initial zoom transform if needed - // d3.select(canvas).call(zoom.transform, d3.zoomIdentity) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dimensions, isTransitioning]); - - // Find node at position - const findNodeAtPosition = (x: number, y: number) => { - for (const node of treemapNodesRef.current) { - if (x >= node.x0 && x <= node.x1 && y >= node.y0 && y <= node.y1) { - if (!node.children) { - return node; - } - } - } - return null; - }; - - // Add mouse event listeners - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const handleMouseMove = (event: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; - - const hit = findNodeAtPosition(mouseX, mouseY); - setHoveredNode(hit); - - // Set cursor style - canvas.style.cursor = hit ? 'pointer' : 'default'; - - if (hit && tooltipRef.current) { - tooltipRef.current.style.opacity = '1'; - tooltipRef.current.style.left = `${event.pageX + 10}px`; - tooltipRef.current.style.top = `${event.pageY + 10}px`; - tooltipRef.current.innerHTML = ` -
${hit.data.name}
-
- Total Clicks: - ${hit.data.clicks?.toLocaleString() || ''} -
-
- Unique Clicks: - ${hit.data.uniqueClicks?.toLocaleString() || ''} -
-
- Click Rate: - ${hit.data.clickRate ? hit.data.clickRate + '%' : ''} -
- `; - } else if (tooltipRef.current) { - tooltipRef.current.style.opacity = '0'; - } - }; - - const handleClick = (event: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; - - const hit = findNodeAtPosition(mouseX, mouseY); - if (hit && !hit.children && hit.data.slug) { - setIsNavigating(true); - setClickedArticle(hit.data.name); - window.gtag('event', 'article_click', { - event_category: hit.parent?.data.name || 'Uncategorized', - event_label: hit.data.name, - event_link: `/posts/${hit.data.slug}`, - transport_type: 'beacon', - }); - router.push(`/posts/${hit.data.slug}`); - } - }; - - canvas.addEventListener('mousemove', handleMouseMove); - canvas.addEventListener('click', handleClick); - return () => { - canvas.removeEventListener('mousemove', handleMouseMove); - canvas.removeEventListener('click', handleClick); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Reset zoom - const handleResetZoom = () => { - if (isTransitioning) return; - - setIsTransitioning(true); - setZoomState({ - scale: 1, - centerX: dimensions.width / 2, - centerY: dimensions.height / 2, - visibleRegion: { - x: 0, - y: 0, - width: dimensions.width, - height: dimensions.height, - }, - }); - - // Reset to the full treemap - treemapNodesRef.current = allNodesRef.current; - draw(); - - setTimeout(() => setIsTransitioning(false), 300); - }; - - // Re-draw when hovered node changes - useEffect(() => { - draw(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hoveredNode]); - - return ( -
- {/* Loading overlay */} - {isNavigating && ( -
-
-
-
-

Loading Article

- {clickedArticle && ( -

{clickedArticle}

- )} -
-
-
- )} - - {/* SEO and accessibility block */} -
- {postsWithMetrics.map((post) => ( -
-

{post.title.replace(/\n\s*/g, ' ')}

-

{post.shortDescription ?? ''}

-

{post.meta?.description ?? ''}

-

- {( - post.content?.root?.children?.[0]?.children?.[0] as { - text: string; - } - )?.text ?? ''} -

-
    - {post.category_titles?.map((category) => ( -
  • {category}
  • - ))} -
- {post.slug && Read more} -
- ))} -
- - {/* The interactive treemap */} -
- -
- - {/* Zoom controls */} -
- -
- - {/* Zoom level indicator */} -
-
- Zoom: {Math.round(zoomState.scale * 100)}% -
-
-
-
- ); -}; - -/** - * WBA (color-coded category list) mobile view for articles - */ -const WBAMobileView: FC<{ posts: Post[] }> = ({ posts }) => { - const router = useRouter(); - const [loadingSlug, setLoadingSlug] = useState(null); - - const postsWithMetrics = useMemo(() => { - return posts.map((post) => { - // Use post ID as seed for consistent random values across SSR and client - const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0; - const clicks = Math.floor(seededRandom(seed) * 2000) + 100; - const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2)); - const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1); - return { ...post, clicks, uniqueClicks, clickRate }; - }); - }, [posts]); - - // Group posts by category and sort by clicks - const categorizedPosts = useMemo(() => { - const categorized: Record = {}; - postsWithMetrics.forEach((post) => { - const category = - post.category_titles && post.category_titles.length > 0 - ? post.category_titles[0] - : 'Uncategorized'; - if (!categorized[category]) categorized[category] = []; - categorized[category].push(post); - }); - // Sort articles within each category by clicks (descending) - Object.keys(categorized).forEach((category) => { - categorized[category].sort((a, b) => b.clicks - a.clicks); - }); - return categorized; - }, [postsWithMetrics]); - - const palette = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6']; - const predefinedColors: Record = { - AI: palette[0], - Earth: palette[1], - }; - - const getCategoryColor = (category: string) => { - if (predefinedColors[category]) return predefinedColors[category]; - const index = Object.keys(categorizedPosts).indexOf(category) % palette.length; - return palette[index]; - }; - - // Calculate max clicks per category for color gradient - const categoryMaxClicks = useMemo(() => { - const maxClicks: Record = {}; - Object.entries(categorizedPosts).forEach(([category, posts]) => { - maxClicks[category] = Math.max(...posts.map((p) => p.clicks)); - }); - return maxClicks; - }, [categorizedPosts]); - - // Get shade color for article based on clicks - const getArticleColor = (category: string, clicks: number) => { - const baseColor = getCategoryColor(category); - const maxClicks = categoryMaxClicks[category] || 1; - return getShade(baseColor, clicks, maxClicks); - }; - - return ( -
- {Object.entries(categorizedPosts).map(([category, categoryPosts]) => ( -
- {/* Category Header */} -
- {category} ({categoryPosts.length}) -
- - {/* Articles in this category */} -
- {categoryPosts.map((post) => ( - { - if (post.slug) { - e.preventDefault(); - setLoadingSlug(post.slug); - window.gtag('event', 'article_click', { - event_category: category, - event_label: post.title, - event_link: `/posts/${post.slug}`, - transport_type: 'beacon', - }); - router.push(`/posts/${post.slug}`); - } - }} - > - {loadingSlug === post.slug && ( -
-
-
- )} - - {post.title.replace(/\n\s*/g, ' ')} - - - {post.clicks?.toLocaleString()} clicks - - - ))} -
-
- ))} -
- ); -}; - -type TimeFilter = 'all' | 'today' | 'week' | 'month' | 'year'; - -/** - * This parent component integrates the search input with the treemap. - * It filters the posts (by title in this example) and passes the filtered - * posts to the ArticleAnalyser. - */ -export const ArticleAnalyser: FC<{ - posts: Post[]; -}> = ({ posts }) => { - const [searchString, _setSearchString] = useState(''); - const [timeFilter, _setTimeFilter] = useState('all'); - const [filteredPosts, setFilteredPosts] = useState(posts); - const [isLoading, setIsLoading] = useState(false); - const [noResults, setNoResults] = useState(false); - const [mobileView, setMobileView] = useState('default'); - - // Apply time filtering - const timeFilteredPosts = useMemo(() => { - if (timeFilter === 'all') return posts; - - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - return posts.filter((post) => { - if (!post.publishedAt) return false; - const publishedDate = new Date(post.publishedAt); - - switch (timeFilter) { - case 'today': - return publishedDate >= today; - case 'week': { - const weekAgo = new Date(today); - weekAgo.setDate(weekAgo.getDate() - 7); - return publishedDate >= weekAgo; - } - case 'month': { - const monthAgo = new Date(today); - monthAgo.setMonth(monthAgo.getMonth() - 1); - return publishedDate >= monthAgo; - } - case 'year': { - const yearAgo = new Date(today); - yearAgo.setFullYear(yearAgo.getFullYear() - 1); - return publishedDate >= yearAgo; - } - default: - return true; - } - }); - }, [posts, timeFilter]); - - useEffect(() => { - if (!searchString) { - setFilteredPosts(timeFilteredPosts); - setNoResults(false); - return; - } - const lowerQuery = searchString.toLowerCase(); - const filtered = timeFilteredPosts.filter((post) => - post.title.toLowerCase().includes(lowerQuery) - ); - // If the search term is a complete word and there are no matches - if (filtered.length === 0 && searchString.trim().includes(' ') === false) { - setNoResults(true); - setIsLoading(true); - - // Mock API call delay - const timer = setTimeout(() => { - setIsLoading(false); - }, 3000); // 3 seconds loading time - - return () => clearTimeout(timer); - } else { - setNoResults(false); - setIsLoading(false); - setFilteredPosts(filtered); - } - }, [searchString, timeFilteredPosts]); - - return ( -
- {/* Search and filter container - row on desktop, stack on mobile */} - {/*
-
-
-
- -
- -
- - Time Period - -
- {(['all', 'today', 'week', 'month', 'year'] as const).map((filter) => ( - - ))} -
-
-
- -
- Showing {filteredPosts.length} {filteredPosts.length === 1 ? 'article' : 'articles'} -
-
-
*/} - - {isLoading ? ( -
-
-
Searching with AI...
-
- Loading -
-
-
- ) : noResults ? ( -
-
-
- No articles found for "{searchString}" -
-
-
- ) : ( - <> - {/* Mobile view with toggle - shown on screens < 768px */} -
- {/* Toggle positioned at top right */} -
- -
- - {/* Render mobile view based on selection */} - {mobileView === 'default' ? ( - - ) : ( - - )} -
- - {/* Desktop treemap view - shown on screens >= 768px */} -
- -
- - )}
); }; diff --git a/src/components/ArticleAnalyser/types.ts b/src/components/ArticleAnalyser/types.ts index b302e3b..6857064 100644 --- a/src/components/ArticleAnalyser/types.ts +++ b/src/components/ArticleAnalyser/types.ts @@ -1,13 +1,13 @@ -import { Post } from '@/payload-types' +import { Post } from '@/payload-types'; /** * Represents a post enriched with additional metrics. * The base `Post` type is assumed to exist elsewhere. */ export interface PostWithMetrics extends Post { - clicks: number - uniqueClicks: number - clickRate: number + clicks: number; + uniqueClicks: number; + clickRate: number; } /** @@ -15,17 +15,49 @@ export interface PostWithMetrics extends Post { * This type is used for both intermediate (category) nodes and leaves. */ export interface HierarchyNode { - name: string - children?: HierarchyNode[] - clicks?: number - uniqueClicks?: number - clickRate?: number - url?: string - slug?: string | null - id?: string | number + name: string; + children?: HierarchyNode[]; + clicks?: number; + uniqueClicks?: number; + clickRate?: number; + url?: string; + slug?: string | null; + id?: string | number; } /** * The metric used to size a treemap node. */ -export type SizeMetric = 'clicks' | 'clickRate' | 'uniqueClicks' +export type SizeMetric = 'clicks' | 'clickRate' | 'uniqueClicks'; + +/** + * View types for article display + * - 'default': Card/Grid view (CardMobileView on mobile, DesktopGridView on desktop) + * - 'wba': Alternative view (WBAMobileView on mobile, ArticleTreeMap on desktop) + */ +export type ViewType = 'default' | 'wba'; + +/** + * @deprecated Use ViewType instead + */ +export type MobileViewType = ViewType; + +/** + * Time filter options for filtering posts by date + */ +export type TimeFilter = 'all' | 'today' | 'week' | 'month' | 'year'; + +/** + * Search input variant styles + */ +export type SearchVariant = 'light' | 'dark'; + +/** + * Mock statistics data structure + */ +export interface MockStats { + gridOutput: string; + aqi: number; + aqiStatus: string; + newNodes: number; +} diff --git a/src/components/ArticleAnalyser/utils.ts b/src/components/ArticleAnalyser/utils.ts new file mode 100644 index 0000000..a370a09 --- /dev/null +++ b/src/components/ArticleAnalyser/utils.ts @@ -0,0 +1,89 @@ +import * as d3 from 'd3'; +import { Post, Media as MediaType } from '@/payload-types'; +import { getClientSideURL } from '@/utilities/getURL'; + +/** + * Simple seeded random number generator for consistent SSR/client results + */ +export const seededRandom = (seed: number): number => { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +}; + +/** + * Returns a shade of the base color based on value and max + * The variation controls how much we brighten/darken + */ +export function getShade( + baseColor: string, + value: number, + max: number, + variation: number = 2 +): string { + const t = max > 0 ? value / max : 0; + const brighter = d3.color(baseColor)?.brighter(variation).formatHex() || baseColor; + const darker = d3.color(baseColor)?.darker(variation).formatHex() || baseColor; + return d3.interpolateLab(brighter, darker)(t); +} + +/** + * Get the image URL for a post, with fallback to future1.webp + */ +export const getImageUrl = (post: Post): string => { + if (post.heroImage && typeof post.heroImage === 'object') { + const media = post.heroImage as MediaType; + if (media.url) { + return `${getClientSideURL()}${media.url}`; + } + } + return '/images/future1.webp'; +}; + +/** + * Mock statistics data + */ +export const getMockStats = () => ({ + gridOutput: '+12%', + aqi: 42, + aqiStatus: 'GOOD', + newNodes: 8, +}); + +/** + * Category color palette - consistent across all visualizations + */ +export const CATEGORY_PALETTE = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6']; + +/** + * Predefined colors for specific categories + */ +export const PREDEFINED_CATEGORY_COLORS: Record = { + AI: CATEGORY_PALETTE[0], + Earth: CATEGORY_PALETTE[1], +}; + +/** + * Get color for a category - uses predefined colors or palette rotation + */ +export const getCategoryColor = (category: string, allCategories: string[]): string => { + if (PREDEFINED_CATEGORY_COLORS[category]) { + return PREDEFINED_CATEGORY_COLORS[category]; + } + const index = allCategories.indexOf(category) % CATEGORY_PALETTE.length; + return CATEGORY_PALETTE[index]; +}; + +/** + * Enrich posts with deterministic metrics (consistent across SSR and client) + */ +export const enrichPostsWithMetrics = ( + posts: Post[] +): Array => { + return posts.map((post) => { + const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0; + const clicks = Math.floor(seededRandom(seed) * 2000) + 100; + const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2)); + const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1); + return { ...post, clicks, uniqueClicks, clickRate }; + }); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000..96b1e5a --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React, { FC } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { cn } from '@/utilities/ui'; + +interface HeaderProps { + className?: string; +} + +export const Header: FC = ({ className }) => { + return ( +
+ +
+ Decentralizard with logo +
+ Decentralizard + + + {/* Right: Navigation + Actions */} + {/*
+ + +
+ + + Open for Collab + +
+ + + +
+ A +
+
*/} +
+ ); +}; diff --git a/src/components/Header/Hero.tsx b/src/components/Header/Hero.tsx new file mode 100644 index 0000000..c1cc44d --- /dev/null +++ b/src/components/Header/Hero.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React, { FC } from 'react'; +import { cn } from '@/utilities/ui'; + +interface HeroProps { + className?: string; + title?: string; + subtitle?: string; + systemStatus?: string; + onNewIdea?: () => void; +} + +export const Hero: FC = ({ + className, + title = 'Digital Laboratory Notebook', + subtitle = 'The intersection of algorithmic precision and creative intuition. Documenting the synthesis of worlds.', + onNewIdea, +}) => { + return ( +
+ {/* Background gradient overlay */} +
+ +
+ {/* Left: Content */} +
+

+ {title} +

+ + {/* Subtitle */} +

+ {subtitle} +

+
+ + {/* Right: CTA Button */} + {onNewIdea && ( + + )} +
+
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000..3185d1c --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1,3 @@ +export { Header } from './Header'; +export { Hero } from './Hero'; +export { SliderFilter } from '@/components/SliderFilter'; diff --git a/src/components/SliderFilter/index.tsx b/src/components/SliderFilter/index.tsx new file mode 100644 index 0000000..c1d47ba --- /dev/null +++ b/src/components/SliderFilter/index.tsx @@ -0,0 +1,55 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/utilities/ui'; +import { Slider } from '@/base/slider'; + +interface SliderFilterProps { + className?: string; + leftLabel: string; + rightLabel: string; + leftColor?: string; + rightColor?: string; + value?: number[]; + defaultValue?: number[]; + min?: number; + max?: number; + step?: number; + onValueChange?: (value: number[]) => void; +} + +export const SliderFilter = ({ + className, + leftLabel, + rightLabel, + leftColor = 'text-cyan-400', + rightColor = 'text-purple-400', + value, + defaultValue = [50], + min = 0, + max = 100, + step = 1, + onValueChange, +}: SliderFilterProps) => { + return ( +
+ +
+ + {leftLabel} + + + {rightLabel} + +
+
+ ); +}; diff --git a/src/heros/PostHero/index.tsx b/src/heros/PostHero/index.tsx index 7992046..23ca6f1 100644 --- a/src/heros/PostHero/index.tsx +++ b/src/heros/PostHero/index.tsx @@ -4,7 +4,6 @@ import Link from 'next/link'; import Image from 'next/image'; import type { Post } from '@/payload-types'; - import { Media } from '@/components/Media'; import { formatAuthors } from '@/utilities/formatAuthors'; import { CategoryPill } from '@/components/CateoryPill'; @@ -38,8 +37,9 @@ export const PostHero: React.FC<{ {title} )} diff --git a/src/store/useCanvasStore.tsx b/src/stores/useCanvasStore.tsx similarity index 69% rename from src/store/useCanvasStore.tsx rename to src/stores/useCanvasStore.tsx index 5cfb8a4..8be4ac5 100644 --- a/src/store/useCanvasStore.tsx +++ b/src/stores/useCanvasStore.tsx @@ -1,10 +1,10 @@ -import { create } from 'zustand' +import { create } from 'zustand'; interface CanvasState { - dimensions: { width: number; height: number } - setDimensions: (dims: { width: number; height: number }) => void - isHydrated: boolean - markHydrated: () => void + dimensions: { width: number; height: number }; + setDimensions: (dims: { width: number; height: number }) => void; + isHydrated: boolean; + markHydrated: () => void; } export const useCanvasStore = create((set) => ({ @@ -12,4 +12,4 @@ export const useCanvasStore = create((set) => ({ setDimensions: (dims) => set({ dimensions: dims }), isHydrated: false, markHydrated: () => set({ isHydrated: true }), -})) +})); diff --git a/src/stores/useFilterStore.ts b/src/stores/useFilterStore.ts new file mode 100644 index 0000000..fb0cc16 --- /dev/null +++ b/src/stores/useFilterStore.ts @@ -0,0 +1,234 @@ +import { create } from 'zustand'; +import { Post } from '@/payload-types'; +import React from 'react'; + +export type TimeFilter = 'all' | 'today' | 'week' | 'month'; + +/** + * Sanitize search query to prevent XSS and injection attacks + * Removes potentially dangerous characters and trims whitespace + */ +const sanitizeSearchQuery = (query: string): string => { + return query.replace(/[<>"']/g, '').trim(); +}; + +interface FilterState { + // Filter values + selectedCategories: string[]; + timeFilter: TimeFilter; + searchQuery: string; + contentBalance: number; // 0-100: 0=analytical, 50=balanced, 100=expressive + + // Actions + toggleCategory: (category: string) => void; + setCategories: (categories: string[]) => void; + setTimeFilter: (filter: TimeFilter) => void; + setSearchQuery: (query: string) => void; + setContentBalance: (balance: number) => void; + resetFilters: () => void; +} + +export const useFilterStore = create((set) => ({ + // Initial state + selectedCategories: [], + timeFilter: 'all', + searchQuery: '', + contentBalance: 50, // Default to middle (show all) + + // Actions + toggleCategory: (category) => + set((state) => ({ + selectedCategories: state.selectedCategories.includes(category) + ? state.selectedCategories.filter((c) => c !== category) + : [...state.selectedCategories, category], + })), + + setCategories: (categories) => set({ selectedCategories: categories }), + + setTimeFilter: (filter) => set({ timeFilter: filter }), + + setSearchQuery: (query) => set({ searchQuery: query }), + + setContentBalance: (balance) => set({ contentBalance: balance }), + + resetFilters: () => + set({ + selectedCategories: [], + timeFilter: 'all', + searchQuery: '', + contentBalance: 50, + }), +})); + +/** + * Maps categories to content balance scores (0-100) + * 0 = Pure Analytical/Science, 50 = Balanced, 100 = Pure Expressive/Arts + */ +const CATEGORY_SCORES: Record = { + // Analytical (0-30) - Tech, Science, Data + AI: 15, + 'Machine Learning': 10, + 'Data Science': 5, + Science: 10, + Technology: 20, + Engineering: 10, + Research: 5, + Mathematics: 0, + Physics: 5, + 'Computer Science': 15, + Blockchain: 20, + Web3: 25, + Crypto: 20, + + // Balanced (30-70) - Interdisciplinary + Earth: 40, + Environment: 45, + Philosophy: 50, + Psychology: 45, + Education: 50, + Business: 40, + Economics: 35, + 'Social Science': 50, + Politics: 45, + History: 50, + Ethics: 55, + Society: 50, + + // Expressive (70-100) - Arts, Creativity, Design + Art: 90, + Design: 80, + Music: 95, + Literature: 85, + 'Creative Writing': 90, + Film: 85, + Photography: 75, + Fashion: 85, + Culture: 70, +}; + +/** + * Calculate content balance score for a post (0-100) + * Returns 50 (balanced) if no recognizable categories + */ +const getPostContentScore = (post: Post): number => { + if (!post.category_titles || post.category_titles.length === 0) { + return 50; // Default to balanced if no categories + } + + // Calculate average score from all categories + const scores = post.category_titles + .map((cat) => CATEGORY_SCORES[cat] ?? 50) + .filter((score) => score !== undefined); + + if (scores.length === 0) return 50; + + return scores.reduce((sum, score) => sum + score, 0) / scores.length; +}; + +/** + * Calculate relevance weight based on distance from target + * Uses a Gaussian-like curve for smooth falloff + */ +const getContentBalanceWeight = ( + postScore: number, + targetScore: number, + tolerance: number = 30 +): number => { + const distance = Math.abs(postScore - targetScore); + + // Gaussian falloff: weight = e^(-(distance^2) / (2 * tolerance^2)) + const weight = Math.exp(-(distance * distance) / (2 * tolerance * tolerance)); + + return weight; +}; + +/** + * Calculate time filter dates once (not on every render) + */ +const getTimeFilterDates = (timeFilter: TimeFilter) => { + if (timeFilter === 'all') return null; + + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (timeFilter) { + case 'today': + return startOfToday; + case 'week': { + const startOfWeek = new Date(startOfToday); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); // Sunday + return startOfWeek; + } + case 'month': + return new Date(now.getFullYear(), now.getMonth(), 1); + default: + return null; + } +}; + +/** + * Selector hook for filtering posts + * Centralizes all filtering logic in one place for efficiency + */ +export const useFilteredPosts = (posts: Post[]) => { + const { selectedCategories, timeFilter, searchQuery, contentBalance } = useFilterStore(); + + // Memoize filtered posts based on filter criteria + return React.useMemo(() => { + let result = posts; + + // Filter by time period (optimized with pre-calculated dates) + if (timeFilter !== 'all') { + const startDate = getTimeFilterDates(timeFilter); + if (startDate) { + result = result.filter((post) => { + const publishedAt = post.publishedAt ? new Date(post.publishedAt) : null; + return publishedAt && publishedAt >= startDate; + }); + } + } + + // Filter by categories (if any selected, show posts that match ANY selected category) + if (selectedCategories.length > 0) { + result = result.filter( + (post) => + post.category_titles && + post.category_titles.some((cat) => selectedCategories.includes(cat)) + ); + } + + // Filter by search query (with sanitization for security) + const sanitizedQuery = sanitizeSearchQuery(searchQuery); + if (sanitizedQuery) { + const query = sanitizedQuery.toLowerCase(); + result = result.filter( + (post) => + post.title.toLowerCase().includes(query) || + post.shortDescription?.toLowerCase().includes(query) + ); + } + + // Apply content balance filtering with weighted scoring + // Early return: Skip expensive calculations when at default position + if (contentBalance !== 50) { + // Calculate score once per post (optimization) + const postsWithWeights = result.map((post) => { + const score = getPostContentScore(post); + return { + post, + score, + weight: getContentBalanceWeight(score, contentBalance), + }; + }); + + // Sort by weight (highest relevance first) + postsWithWeights.sort((a, b) => b.weight - a.weight); + + // Filter out posts with very low weights (< 0.1 means very far from target) + const threshold = 0.1; + result = postsWithWeights.filter((item) => item.weight >= threshold).map((item) => item.post); + } + + return result; + }, [posts, selectedCategories, timeFilter, searchQuery, contentBalance]); +}; diff --git a/src/stores/useViewStore.ts b/src/stores/useViewStore.ts new file mode 100644 index 0000000..8e39bdf --- /dev/null +++ b/src/stores/useViewStore.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { ViewType } from '@/components/ArticleAnalyser/types'; + +interface ViewState { + view: ViewType; + setView: (view: ViewType) => void; +} + +export const useViewStore = create()( + persist( + (set) => ({ + view: 'default', + setView: (view) => set({ view }), + }), + { + name: 'decentralizard-view-storage', // localStorage key + } + ) +); diff --git a/src/stores/useZoomStore.ts b/src/stores/useZoomStore.ts new file mode 100644 index 0000000..ee6d088 --- /dev/null +++ b/src/stores/useZoomStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface ZoomState { + zoomLevel: number; // 1 to 8 (100% to 800%) + setZoomLevel: (level: number) => void; + resetZoom: () => void; +} + +export const useZoomStore = create((set) => ({ + zoomLevel: 1, + setZoomLevel: (level) => set({ zoomLevel: Math.max(1, Math.min(8, level)) }), + resetZoom: () => set({ zoomLevel: 1 }), +})); diff --git a/yarn.lock b/yarn.lock index 454dd2a..d3d5471 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3216,6 +3216,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-accordion@npm:^1.2.12": + version: 1.2.12 + resolution: "@radix-ui/react-accordion@npm:1.2.12" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collapsible": "npm:1.1.12" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/c64a53ce766a1ef529cf6413ed7382598c94f78879b3a83ceda27cb1894ed6eb6e8ad61f6a550ca3c7fa813657045dadfc7328dbf1d736a37e1cf3c446db43de + languageName: node + linkType: hard + "@radix-ui/react-arrow@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-arrow@npm:1.1.7" @@ -3261,6 +3288,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-collapsible@npm:1.1.12": + version: 1.1.12 + resolution: "@radix-ui/react-collapsible@npm:1.1.12" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/777cced73fbbec9cfafe6325aa5605e90f49d889af2778f4c4a6be101c07cacd69ae817d0b41cc27e3181f49392e2c06db7f32d6b084db047a51805ec70729b3 + languageName: node + linkType: hard + "@radix-ui/react-collection@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-collection@npm:1.1.7" @@ -3558,6 +3611,35 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slider@npm:^1.3.6": + version: 1.3.6 + resolution: "@radix-ui/react-slider@npm:1.3.6" + dependencies: + "@radix-ui/number": "npm:1.1.1" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/a53d7854e28c5ef3d29b76c8d04cc3c723b982b643152cd5a8fefc7a8359180f8fd21753e5a08302a290bc837e7be04f2efad9d308b7a4a23326df6a6b1ac882 + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.2.3": version: 1.2.3 resolution: "@radix-ui/react-slot@npm:1.2.3" @@ -7253,9 +7335,11 @@ __metadata: "@payloadcms/plugin-seo": "npm:3.72.0" "@payloadcms/richtext-lexical": "npm:3.72.0" "@payloadcms/ui": "npm:3.72.0" + "@radix-ui/react-accordion": "npm:^1.2.12" "@radix-ui/react-checkbox": "npm:^1.0.4" "@radix-ui/react-label": "npm:^2.0.2" "@radix-ui/react-select": "npm:^2.0.0" + "@radix-ui/react-slider": "npm:^1.3.6" "@radix-ui/react-slot": "npm:^1.0.2" "@radix-ui/react-switch": "npm:^1.2.6" "@supabase/supabase-js": "npm:^2.49.1"