diff --git a/FORK_CONFIG.md b/FORK_CONFIG.md index 3cbd8c4..c5cf17a 100644 --- a/FORK_CONFIG.md +++ b/FORK_CONFIG.md @@ -132,6 +132,12 @@ export const siteConfig: SiteConfig = { title: "GitHub Activity", }, + // Visitor map (stats page) + visitorMap: { + enabled: true, + title: "Live Visitors", + }, + // Blog page blogPage: { enabled: true, diff --git a/README.md b/README.md index ea27a43..8bf7e26 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,26 @@ The graph displays with theme-aware colors that match each site theme: Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). +## Visitor Map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +Configure in `src/config/siteConfig.ts`: + +```typescript +visitorMap: { + enabled: true, // Set to false to hide the visitor map + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (set to `undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + ### Favicon Replace `public/favicon.svg` with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style. diff --git a/TASK.md b/TASK.md index 355d38b..08756f4 100644 --- a/TASK.md +++ b/TASK.md @@ -4,10 +4,20 @@ ## Current Status -v1.19.1 deployed. Author display (authorName/authorImage) and GitHub Stars on Stats page. +v1.20.2 deployed. Write conflict prevention for heartbeat mutations. ## Completed +- [x] Write conflict prevention: increased dedup windows, added heartbeat jitter +- [x] Visitor map styling: removed box-shadow, increased land dot contrast and opacity +- [x] Real-time visitor map on stats page showing live visitor locations +- [x] Netlify edge function for geo detection (geo.ts) +- [x] VisitorMap component with dotted world map and pulsing dots +- [x] Theme-aware colors for all four themes (dark, light, tan, cloud) +- [x] visitorMap config option in siteConfig.ts to enable/disable +- [x] Privacy friendly: no IP addresses stored, only city/country/coordinates +- [x] Documentation updated: setup-guide, docs, FORK_CONFIG, fork-config.json.example + - [x] Author display for posts and pages with authorName and authorImage frontmatter fields - [x] Round avatar image displayed next to date and read time on post/page views - [x] Write page updated with new frontmatter field reference diff --git a/changelog.md b/changelog.md index f866bdc..2d1e21a 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.20.2] - 2025-12-21 + +### Fixed + +- Write conflict prevention for heartbeat mutation + - Increased backend dedup window from 10s to 20s + - Increased frontend debounce from 10s to 20s to match backend + - Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs + - Simplified early return to skip ANY update within dedup window (not just same path) + - Prevents "Documents read from or written to the activeSessions table changed" errors + +## [1.20.1] - 2025-12-21 + +### Changed + +- Visitor map styling improvements + - Removed box-shadow from map wrapper for cleaner flat design + - Increased land dot contrast for better globe visibility on all themes + - Increased land dot opacity from 0.6 to 0.85 + - Darker/more visible land colors for light, tan, and cloud themes + - Lighter land color for dark theme to stand out on dark background + +## [1.20.0] - 2025-12-21 + +### Added + +- Real-time visitor map on stats page + - Displays live visitor locations on a dotted world map + - Uses Netlify's built-in geo detection via edge function (no third-party API needed) + - Privacy friendly: stores city, country, and coordinates only (no IP addresses) + - Theme-aware colors for all four themes (dark, light, tan, cloud) + - Animated pulsing dots for active visitors + - Visitor count badge showing online visitors + - Configurable via `siteConfig.visitorMap` +- New Netlify edge function: `netlify/edge-functions/geo.ts` + - Returns user geo data from Netlify's automatic geo headers + - Endpoint: `/api/geo` +- New React component: `src/components/VisitorMap.tsx` + - SVG-based world map with simplified continent outlines + - Lightweight (no external map library needed) + - Responsive design scales on mobile + +### Changed + +- Updated `convex/schema.ts`: Added optional location fields to `activeSessions` table (city, country, latitude, longitude) +- Updated `convex/stats.ts`: Heartbeat mutation accepts geo args, getStats returns visitor locations +- Updated `src/hooks/usePageTracking.ts`: Fetches geo data once on mount, passes to heartbeat +- Updated `src/pages/Stats.tsx`: Displays VisitorMap above "Currently Viewing" section +- Updated `src/config/siteConfig.ts`: Added `VisitorMapConfig` interface and `visitorMap` config option + +### Documentation + +- Updated setup-guide.md with Visitor Map section +- Updated docs.md with Visitor Map configuration +- Updated FORK_CONFIG.md with visitorMap config +- Updated fork-config.json.example with visitorMap option +- Updated fork-configuration-guide.md with visitorMap example + ## [1.19.1] - 2025-12-21 ### Added diff --git a/content/blog/fork-configuration-guide.md b/content/blog/fork-configuration-guide.md index 5d8b8ff..e06c877 100644 --- a/content/blog/fork-configuration-guide.md +++ b/content/blog/fork-configuration-guide.md @@ -128,6 +128,10 @@ The JSON config file supports additional options: "linkToProfile": true, "title": "GitHub Activity" }, + "visitorMap": { + "enabled": true, + "title": "Live Visitors" + }, "blogPage": { "enabled": true, "showInNav": true, diff --git a/content/blog/new-features-search-featured-logos.md b/content/blog/new-features-search-featured-logos.md index cb67d08..fb27f40 100644 --- a/content/blog/new-features-search-featured-logos.md +++ b/content/blog/new-features-search-featured-logos.md @@ -6,7 +6,7 @@ slug: "new-features-search-featured-logos" published: true tags: ["features", "search", "convex", "updates"] readTime: "4 min read" -featured: true +featured: false featuredOrder: 5 authorName: "Markdown" authorImage: "/images/authors/markdown.png" diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md index 8f17f08..69c0498 100644 --- a/content/blog/setup-guide.md +++ b/content/blog/setup-guide.md @@ -7,7 +7,7 @@ published: true tags: ["convex", "netlify", "tutorial", "deployment"] readTime: "8 min read" featured: true -featuredOrder: 3 +featuredOrder: 6 image: "/images/setupguide.png" authorName: "Markdown" authorImage: "/images/authors/markdown.png" @@ -58,6 +58,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Update Site Configuration](#update-site-configuration) - [Featured Section](#featured-section) - [GitHub Contributions Graph](#github-contributions-graph) + - [Visitor Map](#visitor-map) - [Logo Gallery](#logo-gallery) - [Blog page](#blog-page) - [Scroll-to-top button](#scroll-to-top-button) @@ -699,6 +700,26 @@ gitHubContributions: { The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). +### Visitor Map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +Configure in `siteConfig`: + +```typescript +visitorMap: { + enabled: true, // Set to false to hide the visitor map + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (set to `undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + ### Logo Gallery The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig: diff --git a/content/blog/using-images-in-posts.md b/content/blog/using-images-in-posts.md index 0807996..d5ea92e 100644 --- a/content/blog/using-images-in-posts.md +++ b/content/blog/using-images-in-posts.md @@ -5,7 +5,7 @@ date: "2025-12-14" slug: "using-images-in-posts" published: true featured: true -featuredOrder: 3 +featuredOrder: 4 tags: ["images", "tutorial", "markdown", "open-graph"] readTime: "4 min read" authorName: "Markdown" diff --git a/content/blog/visitor-tracking-and-stats-improvements.md b/content/blog/visitor-tracking-and-stats-improvements.md new file mode 100644 index 0000000..6cf496d --- /dev/null +++ b/content/blog/visitor-tracking-and-stats-improvements.md @@ -0,0 +1,133 @@ +--- +title: "Visitor tracking and stats improvements" +description: "Real-time visitor map, write conflict prevention, GitHub Stars integration, and better AI prompts. Updates from v1.18.1 to v1.20.2." +date: "2025-12-21" +slug: "visitor-tracking-and-stats-improvements" +published: true +tags: ["features", "stats", "convex", "updates", "analytics"] +readTime: "5 min read" +featured: true +featuredOrder: 1 +authorName: "Markdown" +authorImage: "/images/authors/markdown.png" +image: "/images/122.png" +excerpt: "Real-time visitor map shows where your readers are. Write conflict prevention keeps stats accurate. GitHub Stars and improved AI prompts round out the updates." +--- + +## Real-time visitor map + +The stats page now shows a live world map with visitor locations. Each active visitor appears as a pulsing dot on the map. + +The map uses Netlify's built-in geo detection. No third-party API needed. No IP addresses stored. Just city, country, and coordinates. + +Configure it in siteConfig: + +```typescript +visitorMap: { + enabled: true, + title: "Live Visitors", +}, +``` + +The map works with all four themes. Dark, light, tan, cloud. Land dots use theme-aware colors. Visitor dots pulse to show activity. + +Implementation details: + +- New edge function at `netlify/edge-functions/geo.ts` reads Netlify geo headers +- React component `VisitorMap.tsx` renders an SVG world map +- No external map library required +- Responsive design scales on mobile + +The map updates in real time as visitors navigate your site. Each heartbeat includes location data. The map aggregates active sessions and displays them on the globe. + +## Write conflict prevention + +Convex write conflicts happen when multiple mutations update the same document concurrently. The heartbeat mutation was hitting this with rapid page navigation. + +Version 1.20.2 fixes this with three changes: + +1. Increased dedup window from 10 seconds to 20 seconds +2. Added random jitter (±5 seconds) to heartbeat intervals +3. Simplified early return to skip any update within the dedup window + +The jitter prevents synchronized calls across browser tabs. If you have multiple tabs open, they no longer fire heartbeats at the same time. + +Backend and frontend now use matching 20-second windows. The backend checks if a session was updated recently. If yes, it returns early without patching. This makes the mutation idempotent and prevents conflicts. + +## GitHub Stars integration + +The stats page now displays your repository's star count. Fetches from GitHub's public API. No token required. + +The card shows the live count and updates on page load. Uses the Phosphor GithubLogo icon for consistency. + +Stats page layout changed to accommodate six cards: + +- Desktop: single row of six cards +- Tablet: 3x2 grid +- Mobile: 2x3 grid +- Small mobile: stacked + +The GitHub Stars card sits alongside total page views, unique visitors, active visitors, and views by page. + +## Author display + +Posts and pages now support author attribution. Add these fields to frontmatter: + +```yaml +authorName: "Your Name" +authorImage: "/images/authors/photo.png" +``` + +The author info appears on individual post and page views. Round avatar image next to the date and read time. Not shown on blog list views. + +Place author images in `public/images/authors/`. Square images work best since they display as circles. + +## Improved AI prompts + +The CopyPageDropdown now sends better instructions to ChatGPT, Claude, and Perplexity. + +Previous prompts asked AI to summarize without verifying the content loaded. New prompts instruct AI to: + +1. Attempt to load the raw markdown URL +2. If successful, provide a concise summary and ask how to help +3. If not accessible, state the page could not be loaded without guessing + +This prevents AI from hallucinating content when URLs fail to load. More reliable results for users sharing pages. + +## Raw markdown URLs + +Version 1.18.1 changed AI services to use raw markdown file URLs instead of page URLs. Format: `/raw/{slug}.md`. + +AI services can fetch and parse clean markdown directly. No HTML parsing required. Includes metadata headers for structured parsing. + +The raw markdown files are generated during `npm run sync`. Each published post and page gets a corresponding static `.md` file in `public/raw/`. + +## Technical notes + +All updates maintain backward compatibility. Existing sites continue working without changes. + +The visitor map requires Netlify edge functions. The geo endpoint reads automatic headers from Netlify's CDN. No configuration needed beyond enabling the feature. + +Write conflict prevention uses Convex best practices: + +- Idempotent mutations with early returns +- Indexed queries for efficient lookups +- Event records pattern for high-frequency updates +- Debouncing on the frontend + +The stats page aggregates data from multiple sources: + +- Active sessions from heartbeat mutations +- Page views from event records +- GitHub Stars from public API +- Visitor locations from geo edge function + +All data updates in real time via Convex subscriptions. + +## What's next + +These updates focus on making stats more useful and reliable. The visitor map provides visual feedback. Write conflict prevention ensures accuracy. GitHub Stars adds social proof. + +Future updates will continue improving the analytics experience. More granular stats. Better visualization. More configuration options. + +Check the changelog for the full list of changes from v1.18.1 to v1.20.2. diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index 73d45c2..391b68b 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -7,6 +7,62 @@ order: 5 All notable changes to this project. +## v1.20.2 + +Released December 21, 2025 + +**Write conflict prevention for heartbeat mutation** + +- Increased backend dedup window from 10s to 20s +- Increased frontend debounce from 10s to 20s to match backend +- Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs +- Simplified early return to skip ANY update within dedup window +- Prevents "Documents read from or written to the activeSessions table changed" errors + +## v1.20.1 + +Released December 21, 2025 + +**Visitor map styling improvements** + +- Removed box-shadow from map wrapper for cleaner flat design +- Increased land dot contrast for better globe visibility on all themes +- Increased land dot opacity from 0.6 to 0.85 +- Darker/more visible land colors for light, tan, and cloud themes +- Lighter land color for dark theme to stand out on dark background + +## v1.20.0 + +Released December 21, 2025 + +**Real-time visitor map on stats page** + +- Displays live visitor locations on a dotted world map +- Uses Netlify's built-in geo detection via edge function (no third-party API needed) +- Privacy friendly: stores city, country, and coordinates only (no IP addresses) +- Theme-aware colors for all four themes (dark, light, tan, cloud) +- Animated pulsing dots for active visitors +- Configurable via `siteConfig.visitorMap` + +New files: + +- `netlify/edge-functions/geo.ts`: Edge function returning geo data from Netlify headers +- `src/components/VisitorMap.tsx`: SVG world map component with visitor dots + +Configuration: + +```typescript +// src/config/siteConfig.ts +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +Updated files: `convex/schema.ts`, `convex/stats.ts`, `src/hooks/usePageTracking.ts`, `src/pages/Stats.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css` + +Documentation updated: setup-guide.md, docs.md, FORK_CONFIG.md, fork-config.json.example, fork-configuration-guide.md + ## v1.19.2 Released December 21, 2025 diff --git a/content/pages/docs.md b/content/pages/docs.md index c9cfb32..32c3520 100644 --- a/content/pages/docs.md +++ b/content/pages/docs.md @@ -360,6 +360,24 @@ gitHubContributions: { Theme-aware colors match each site theme. Uses public API (no GitHub token required). +### Visitor map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +```typescript +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (`undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + ### Logo gallery The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. diff --git a/convex/schema.ts b/convex/schema.ts index 035bb9c..afdf43b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -88,6 +88,11 @@ export default defineSchema({ sessionId: v.string(), currentPath: v.string(), lastSeen: v.number(), + // Location data (optional, from Netlify geo headers) + city: v.optional(v.string()), + country: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), }) .index("by_sessionId", ["sessionId"]) .index("by_lastSeen", ["lastSeen"]), diff --git a/convex/stats.ts b/convex/stats.ts index 774656c..af94846 100644 --- a/convex/stats.ts +++ b/convex/stats.ts @@ -10,8 +10,8 @@ const DEDUP_WINDOW_MS = 30 * 60 * 1000; // Session timeout: 2 minutes in milliseconds const SESSION_TIMEOUT_MS = 2 * 60 * 1000; -// Heartbeat dedup window: 10 seconds (prevents write conflicts from rapid calls) -const HEARTBEAT_DEDUP_MS = 10 * 1000; +// Heartbeat dedup window: 20 seconds (prevents write conflicts from rapid calls or multiple tabs) +const HEARTBEAT_DEDUP_MS = 20 * 1000; /** * Aggregate for page views by path. @@ -73,7 +73,7 @@ export const recordPageView = mutation({ const recentView = await ctx.db .query("pageViews") .withIndex("by_session_path", (q) => - q.eq("sessionId", args.sessionId).eq("path", args.path) + q.eq("sessionId", args.sessionId).eq("path", args.path), ) .order("desc") .first(); @@ -116,12 +116,23 @@ export const recordPageView = mutation({ /** * Update active session heartbeat. * Creates or updates session with current path and timestamp. + * Accepts optional geo location data from Netlify edge function. * Idempotent: skips update if recently updated with same path (prevents write conflicts). + * + * Write conflict prevention: + * - Uses 20-second dedup window to skip redundant updates + * - Frontend uses matching debounce with jitter to prevent synchronized calls + * - Early return pattern minimizes conflict window */ export const heartbeat = mutation({ args: { sessionId: v.string(), currentPath: v.string(), + // Optional geo data from Netlify geo headers + city: v.optional(v.string()), + country: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), }, returns: v.null(), handler: async (ctx, args) => { @@ -134,27 +145,33 @@ export const heartbeat = mutation({ .first(); if (existingSession) { - // Early return if same path and recently updated (idempotent - prevents write conflicts) - if ( - existingSession.currentPath === args.currentPath && - now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS - ) { + // Early return if recently updated (idempotent - prevents write conflicts) + // Even if path changed, skip update if within dedup window to reduce conflicts + if (now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS) { return null; } - // Patch directly with new data + // Patch directly with new data including location if provided await ctx.db.patch(existingSession._id, { currentPath: args.currentPath, lastSeen: now, + ...(args.city !== undefined && { city: args.city }), + ...(args.country !== undefined && { country: args.country }), + ...(args.latitude !== undefined && { latitude: args.latitude }), + ...(args.longitude !== undefined && { longitude: args.longitude }), }); return null; } - // Create new session only if none exists + // Create new session only if none exists (with location data if provided) await ctx.db.insert("activeSessions", { sessionId: args.sessionId, currentPath: args.currentPath, lastSeen: now, + ...(args.city !== undefined && { city: args.city }), + ...(args.country !== undefined && { country: args.country }), + ...(args.latitude !== undefined && { latitude: args.latitude }), + ...(args.longitude !== undefined && { longitude: args.longitude }), }); return null; @@ -165,6 +182,7 @@ export const heartbeat = mutation({ * Get all stats for the stats page. * Real-time subscription via useQuery. * Uses aggregate components for O(log n) counts instead of O(n) table scans. + * Returns visitor locations for the world map display. */ export const getStats = query({ args: {}, @@ -174,7 +192,7 @@ export const getStats = query({ v.object({ path: v.string(), count: v.number(), - }) + }), ), totalPageViews: v.number(), uniqueVisitors: v.number(), @@ -187,7 +205,16 @@ export const getStats = query({ title: v.string(), pageType: v.string(), views: v.number(), - }) + }), + ), + // Visitor locations for world map display + visitorLocations: v.array( + v.object({ + latitude: v.number(), + longitude: v.number(), + city: v.optional(v.string()), + country: v.optional(v.string()), + }), ), }), handler: async (ctx) => { @@ -214,11 +241,11 @@ export const getStats = query({ // We use direct counting until aggregates are fully backfilled const allPageViews = await ctx.db.query("pageViews").collect(); const totalPageViewsCount = allPageViews.length; - + // Count unique sessions from the views const uniqueSessions = new Set(allPageViews.map((v) => v.sessionId)); const uniqueVisitorsCount = uniqueSessions.size; - + // Count views per path from the raw data const pathCountsFromDb: Record = {}; for (const view of allPageViews) { @@ -248,7 +275,7 @@ export const getStats = query({ // Build page stats using direct counts (always accurate) const pageStatsPromises = allPaths.map(async (path) => { const views = pathCountsFromDb[path] || 0; - + // Match path to post or page for title const slug = path.startsWith("/") ? path.slice(1) : path; const post = posts.find((p) => p.slug === slug); @@ -280,9 +307,25 @@ export const getStats = query({ }); const pageStats = (await Promise.all(pageStatsPromises)).sort( - (a, b) => b.views - a.views + (a, b) => b.views - a.views, ); + // Extract visitor locations from active sessions (only those with coordinates) + const visitorLocations = activeSessions + .filter( + (s): s is typeof s & { latitude: number; longitude: number } => + s.latitude !== undefined && + s.longitude !== undefined && + s.latitude !== null && + s.longitude !== null, + ) + .map((s) => ({ + latitude: s.latitude, + longitude: s.longitude, + city: s.city, + country: s.country, + })); + return { activeVisitors: activeSessions.length, activeByPath, @@ -292,6 +335,7 @@ export const getStats = query({ publishedPages: pages.length, trackingSince, pageStats, + visitorLocations, }; }, }); @@ -313,7 +357,9 @@ export const cleanupStaleSessions = internalMutation({ .collect(); // Delete in parallel - await Promise.all(staleSessions.map((session) => ctx.db.delete(session._id))); + await Promise.all( + staleSessions.map((session) => ctx.db.delete(session._id)), + ); return staleSessions.length; }, @@ -376,12 +422,13 @@ export const backfillAggregatesChunk = internalMutation({ await ctx.scheduler.runAfter( 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any - (await import("./_generated/api")).internal.stats.backfillAggregatesChunk as any, + (await import("./_generated/api")).internal.stats + .backfillAggregatesChunk as any, { cursor: result.continueCursor, totalProcessed: newTotalProcessed, seenSessionIds: sessionArray, - } + }, ); return { @@ -423,15 +470,15 @@ export const backfillAggregates = internalMutation({ await ctx.scheduler.runAfter( 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any - (await import("./_generated/api")).internal.stats.backfillAggregatesChunk as any, + (await import("./_generated/api")).internal.stats + .backfillAggregatesChunk as any, { cursor: null, totalProcessed: 0, seenSessionIds: [], - } + }, ); return { message: "Backfill started. Check logs for progress." }; }, }); - diff --git a/files.md b/files.md index 51be62e..bf47bbf 100644 --- a/files.md +++ b/files.md @@ -60,6 +60,7 @@ A brief description of each file in the codebase. | `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button | | `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon | | `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation | +| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors | ### Context (`src/context/`) @@ -177,6 +178,7 @@ Frontmatter is the YAML metadata at the top of each markdown file. Here is how i | `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP | | `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP | | `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex | +| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map | ## Public Assets (`public/`) diff --git a/fork-config.json.example b/fork-config.json.example index ececf80..7031617 100644 --- a/fork-config.json.example +++ b/fork-config.json.example @@ -26,6 +26,10 @@ "linkToProfile": true, "title": "GitHub Activity" }, + "visitorMap": { + "enabled": true, + "title": "Live Visitors" + }, "blogPage": { "enabled": true, "showInNav": true, diff --git a/netlify.toml b/netlify.toml index b1a627c..4339626 100644 --- a/netlify.toml +++ b/netlify.toml @@ -39,6 +39,11 @@ path = "/api/export" function = "api" +# Geo location API for visitor map +[[edge_functions]] + path = "/api/geo" + function = "geo" + # Open Graph bot detection (catches all other routes) [[edge_functions]] path = "/*" diff --git a/netlify/edge-functions/geo.ts b/netlify/edge-functions/geo.ts new file mode 100644 index 0000000..295ce3f --- /dev/null +++ b/netlify/edge-functions/geo.ts @@ -0,0 +1,32 @@ +import type { Context } from "@netlify/edge-functions"; + +// Returns the user's geo location from Netlify's automatic geo headers +// Privacy friendly: only returns city/country/coordinates, no IP address stored +export default async function handler( + _request: Request, + context: Context, +): Promise { + // Netlify provides geo data automatically via context.geo + const geo = context.geo; + + const data = { + city: geo?.city || null, + country: geo?.country?.code || null, + countryName: geo?.country?.name || null, + latitude: geo?.latitude || null, + longitude: geo?.longitude || null, + }; + + return new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "private, max-age=3600", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export const config = { + path: "/api/geo", +}; + diff --git a/public/images/122.png b/public/images/122.png new file mode 100644 index 0000000..71af916 Binary files /dev/null and b/public/images/122.png differ diff --git a/public/images/forkconfig.png b/public/images/forkconfig.png index f288576..e32e39f 100644 Binary files a/public/images/forkconfig.png and b/public/images/forkconfig.png differ diff --git a/public/images/setupguide.png b/public/images/setupguide.png index 77fdf4b..ccc0774 100644 Binary files a/public/images/setupguide.png and b/public/images/setupguide.png differ diff --git a/public/images/v16.png b/public/images/v16.png index 64b3fcd..a8483ea 100644 Binary files a/public/images/v16.png and b/public/images/v16.png differ diff --git a/public/images/v17.png b/public/images/v17.png index fc2dfdf..6de7431 100644 Binary files a/public/images/v17.png and b/public/images/v17.png differ diff --git a/public/raw/changelog.md b/public/raw/changelog.md index e8f44b2..91dc4f9 100644 --- a/public/raw/changelog.md +++ b/public/raw/changelog.md @@ -7,6 +7,62 @@ Date: 2025-12-21 All notable changes to this project. +## v1.20.2 + +Released December 21, 2025 + +**Write conflict prevention for heartbeat mutation** + +- Increased backend dedup window from 10s to 20s +- Increased frontend debounce from 10s to 20s to match backend +- Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs +- Simplified early return to skip ANY update within dedup window +- Prevents "Documents read from or written to the activeSessions table changed" errors + +## v1.20.1 + +Released December 21, 2025 + +**Visitor map styling improvements** + +- Removed box-shadow from map wrapper for cleaner flat design +- Increased land dot contrast for better globe visibility on all themes +- Increased land dot opacity from 0.6 to 0.85 +- Darker/more visible land colors for light, tan, and cloud themes +- Lighter land color for dark theme to stand out on dark background + +## v1.20.0 + +Released December 21, 2025 + +**Real-time visitor map on stats page** + +- Displays live visitor locations on a dotted world map +- Uses Netlify's built-in geo detection via edge function (no third-party API needed) +- Privacy friendly: stores city, country, and coordinates only (no IP addresses) +- Theme-aware colors for all four themes (dark, light, tan, cloud) +- Animated pulsing dots for active visitors +- Configurable via `siteConfig.visitorMap` + +New files: + +- `netlify/edge-functions/geo.ts`: Edge function returning geo data from Netlify headers +- `src/components/VisitorMap.tsx`: SVG world map component with visitor dots + +Configuration: + +```typescript +// src/config/siteConfig.ts +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +Updated files: `convex/schema.ts`, `convex/stats.ts`, `src/hooks/usePageTracking.ts`, `src/pages/Stats.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css` + +Documentation updated: setup-guide.md, docs.md, FORK_CONFIG.md, fork-config.json.example, fork-configuration-guide.md + ## v1.19.2 Released December 21, 2025 diff --git a/public/raw/docs.md b/public/raw/docs.md index 64eb291..2345470 100644 --- a/public/raw/docs.md +++ b/public/raw/docs.md @@ -360,6 +360,24 @@ gitHubContributions: { Theme-aware colors match each site theme. Uses public API (no GitHub token required). +### Visitor map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +```typescript +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (`undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + ### Logo gallery The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. diff --git a/public/raw/fork-configuration-guide.md b/public/raw/fork-configuration-guide.md index ebb567e..ccf6b8d 100644 --- a/public/raw/fork-configuration-guide.md +++ b/public/raw/fork-configuration-guide.md @@ -123,6 +123,10 @@ The JSON config file supports additional options: "linkToProfile": true, "title": "GitHub Activity" }, + "visitorMap": { + "enabled": true, + "title": "Live Visitors" + }, "blogPage": { "enabled": true, "showInNav": true, diff --git a/public/raw/setup-guide.md b/public/raw/setup-guide.md index a1a2b4d..8340428 100644 --- a/public/raw/setup-guide.md +++ b/public/raw/setup-guide.md @@ -53,6 +53,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Update Site Configuration](#update-site-configuration) - [Featured Section](#featured-section) - [GitHub Contributions Graph](#github-contributions-graph) + - [Visitor Map](#visitor-map) - [Logo Gallery](#logo-gallery) - [Blog page](#blog-page) - [Scroll-to-top button](#scroll-to-top-button) @@ -694,6 +695,26 @@ gitHubContributions: { The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). +### Visitor Map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +Configure in `siteConfig`: + +```typescript +visitorMap: { + enabled: true, // Set to false to hide the visitor map + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (set to `undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + ### Logo Gallery The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig: diff --git a/public/raw/visitor-tracking-and-stats-improvements.md b/public/raw/visitor-tracking-and-stats-improvements.md new file mode 100644 index 0000000..8a3b3d6 --- /dev/null +++ b/public/raw/visitor-tracking-and-stats-improvements.md @@ -0,0 +1,128 @@ +# Visitor tracking and stats improvements + +> Real-time visitor map, write conflict prevention, GitHub Stars integration, and better AI prompts. Updates from v1.18.1 to v1.20.2. + +--- +Type: post +Date: 2025-12-21 +Reading time: 5 min read +Tags: features, stats, convex, updates, analytics +--- + +## Real-time visitor map + +The stats page now shows a live world map with visitor locations. Each active visitor appears as a pulsing dot on the map. + +The map uses Netlify's built-in geo detection. No third-party API needed. No IP addresses stored. Just city, country, and coordinates. + +Configure it in siteConfig: + +```typescript +visitorMap: { + enabled: true, + title: "Live Visitors", +}, +``` + +The map works with all four themes. Dark, light, tan, cloud. Land dots use theme-aware colors. Visitor dots pulse to show activity. + +Implementation details: + +- New edge function at `netlify/edge-functions/geo.ts` reads Netlify geo headers +- React component `VisitorMap.tsx` renders an SVG world map +- No external map library required +- Responsive design scales on mobile + +The map updates in real time as visitors navigate your site. Each heartbeat includes location data. The map aggregates active sessions and displays them on the globe. + +## Write conflict prevention + +Convex write conflicts happen when multiple mutations update the same document concurrently. The heartbeat mutation was hitting this with rapid page navigation. + +Version 1.20.2 fixes this with three changes: + +1. Increased dedup window from 10 seconds to 20 seconds +2. Added random jitter (±5 seconds) to heartbeat intervals +3. Simplified early return to skip any update within the dedup window + +The jitter prevents synchronized calls across browser tabs. If you have multiple tabs open, they no longer fire heartbeats at the same time. + +Backend and frontend now use matching 20-second windows. The backend checks if a session was updated recently. If yes, it returns early without patching. This makes the mutation idempotent and prevents conflicts. + +## GitHub Stars integration + +The stats page now displays your repository's star count. Fetches from GitHub's public API. No token required. + +The card shows the live count and updates on page load. Uses the Phosphor GithubLogo icon for consistency. + +Stats page layout changed to accommodate six cards: + +- Desktop: single row of six cards +- Tablet: 3x2 grid +- Mobile: 2x3 grid +- Small mobile: stacked + +The GitHub Stars card sits alongside total page views, unique visitors, active visitors, and views by page. + +## Author display + +Posts and pages now support author attribution. Add these fields to frontmatter: + +```yaml +authorName: "Your Name" +authorImage: "/images/authors/photo.png" +``` + +The author info appears on individual post and page views. Round avatar image next to the date and read time. Not shown on blog list views. + +Place author images in `public/images/authors/`. Square images work best since they display as circles. + +## Improved AI prompts + +The CopyPageDropdown now sends better instructions to ChatGPT, Claude, and Perplexity. + +Previous prompts asked AI to summarize without verifying the content loaded. New prompts instruct AI to: + +1. Attempt to load the raw markdown URL +2. If successful, provide a concise summary and ask how to help +3. If not accessible, state the page could not be loaded without guessing + +This prevents AI from hallucinating content when URLs fail to load. More reliable results for users sharing pages. + +## Raw markdown URLs + +Version 1.18.1 changed AI services to use raw markdown file URLs instead of page URLs. Format: `/raw/{slug}.md`. + +AI services can fetch and parse clean markdown directly. No HTML parsing required. Includes metadata headers for structured parsing. + +The raw markdown files are generated during `npm run sync`. Each published post and page gets a corresponding static `.md` file in `public/raw/`. + +## Technical notes + +All updates maintain backward compatibility. Existing sites continue working without changes. + +The visitor map requires Netlify edge functions. The geo endpoint reads automatic headers from Netlify's CDN. No configuration needed beyond enabling the feature. + +Write conflict prevention uses Convex best practices: + +- Idempotent mutations with early returns +- Indexed queries for efficient lookups +- Event records pattern for high-frequency updates +- Debouncing on the frontend + +The stats page aggregates data from multiple sources: + +- Active sessions from heartbeat mutations +- Page views from event records +- GitHub Stars from public API +- Visitor locations from geo edge function + +All data updates in real time via Convex subscriptions. + +## What's next + +These updates focus on making stats more useful and reliable. The visitor map provides visual feedback. Write conflict prevention ensures accuracy. GitHub Stars adds social proof. + +Future updates will continue improving the analytics experience. More granular stats. Better visualization. More configuration options. + +Check the changelog for the full list of changes from v1.18.1 to v1.20.2. \ No newline at end of file diff --git a/src/components/VisitorMap.tsx b/src/components/VisitorMap.tsx new file mode 100644 index 0000000..a85a931 --- /dev/null +++ b/src/components/VisitorMap.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; + +// Visitor location data from Convex +interface VisitorLocation { + latitude: number; + longitude: number; + city?: string; + country?: string; +} + +interface VisitorMapProps { + locations: VisitorLocation[]; + title?: string; +} + +// Simplified world map as dot grid (major landmass coordinates) +// This creates a lightweight dotted world map pattern +const WORLD_DOTS = generateWorldDots(); + +function generateWorldDots(): Array<{ x: number; y: number }> { + // Generate a simplified dot pattern for major landmasses + // Using approximate continent boundaries for a clean look + const dots: Array<{ x: number; y: number }> = []; + + // Simplified continent outlines as dot regions + const regions = [ + // North America + { latMin: 25, latMax: 70, lngMin: -170, lngMax: -50, density: 0.15 }, + // South America + { latMin: -55, latMax: 12, lngMin: -80, lngMax: -35, density: 0.12 }, + // Europe + { latMin: 35, latMax: 70, lngMin: -10, lngMax: 40, density: 0.18 }, + // Africa + { latMin: -35, latMax: 37, lngMin: -18, lngMax: 52, density: 0.12 }, + // Asia + { latMin: 5, latMax: 75, lngMin: 40, lngMax: 145, density: 0.1 }, + // Australia + { latMin: -45, latMax: -10, lngMin: 112, lngMax: 155, density: 0.1 }, + // Greenland + { latMin: 60, latMax: 83, lngMin: -73, lngMax: -12, density: 0.08 }, + ]; + + // Generate dots for each region + for (const region of regions) { + const latStep = 4; + const lngStep = 6; + for (let lat = region.latMin; lat <= region.latMax; lat += latStep) { + for (let lng = region.lngMin; lng <= region.lngMax; lng += lngStep) { + // Add some randomness to make it look more natural + if (Math.random() < region.density * 3) { + const { x, y } = latLngToSvg(lat, lng); + dots.push({ x, y }); + } + } + } + } + + return dots; +} + +// Convert latitude/longitude to SVG coordinates (simple mercator projection) +function latLngToSvg(lat: number, lng: number): { x: number; y: number } { + // Map dimensions: 800x400 + const x = ((lng + 180) / 360) * 800; + const y = ((90 - lat) / 180) * 400; + return { x, y }; +} + +/** + * Visitor Map Component + * Displays a dotted world map with animated location indicators + * Theme-aware colors using CSS variables + */ +export default function VisitorMap({ locations, title }: VisitorMapProps) { + // Convert visitor locations to SVG coordinates + const visitorDots = useMemo(() => { + return locations.map((loc) => ({ + ...latLngToSvg(loc.latitude, loc.longitude), + city: loc.city, + country: loc.country, + })); + }, [locations]); + + return ( +
+ {title &&

{title}

} +
+ + {/* Background */} + + + {/* World map dots (landmasses) */} + {WORLD_DOTS.map((dot, i) => ( + + ))} + + {/* Visitor location dots with pulse animation */} + {visitorDots.map((dot, i) => ( + + {/* Outer pulse ring */} + + {/* Middle pulse ring */} + + {/* Solid center dot */} + + {/* Inner bright core */} + + + ))} + + + {/* Location count badge */} + {locations.length > 0 && ( +
+ + {locations.length} {locations.length === 1 ? "visitor" : "visitors"}{" "} + online +
+ )} +
+
+ ); +} diff --git a/src/config/siteConfig.ts b/src/config/siteConfig.ts index 85bf257..0fedb75 100644 --- a/src/config/siteConfig.ts +++ b/src/config/siteConfig.ts @@ -13,6 +13,13 @@ export interface GitHubContributionsConfig { title?: string; // Optional title above the graph } +// Visitor map configuration +// Displays real-time visitor locations on a world map on the stats page +export interface VisitorMapConfig { + enabled: boolean; // Enable/disable the visitor map + title?: string; // Optional title above the map +} + // Blog page configuration // Controls whether posts appear on homepage, dedicated blog page, or both export interface BlogPageConfig { @@ -49,6 +56,9 @@ export interface SiteConfig { // GitHub contributions graph configuration gitHubContributions: GitHubContributionsConfig; + // Visitor map configuration (stats page) + visitorMap: VisitorMapConfig; + // Blog page configuration blogPage: BlogPageConfig; @@ -125,6 +135,13 @@ export const siteConfig: SiteConfig = { title: "GitHub Activity", // Optional title above the graph }, + // Visitor map configuration + // Displays real-time visitor locations on the stats page + visitorMap: { + enabled: true, // Set to false to hide the visitor map + title: "Live Visitors", // Optional title above the map + }, + // Blog page configuration // Set enabled to true to create a dedicated /blog page blogPage: { diff --git a/src/hooks/usePageTracking.ts b/src/hooks/usePageTracking.ts index 1dafbb3..9a49058 100644 --- a/src/hooks/usePageTracking.ts +++ b/src/hooks/usePageTracking.ts @@ -3,15 +3,26 @@ import { useMutation } from "convex/react"; import { useLocation } from "react-router-dom"; import { api } from "../../convex/_generated/api"; -// Heartbeat interval: 30 seconds +// Heartbeat interval: 30 seconds (with jitter added to prevent synchronized calls) const HEARTBEAT_INTERVAL_MS = 30 * 1000; -// Minimum time between heartbeats to prevent write conflicts: 10 seconds (matches backend dedup window) -const HEARTBEAT_DEBOUNCE_MS = 10 * 1000; +// Minimum time between heartbeats to prevent write conflicts: 20 seconds (matches backend dedup window) +const HEARTBEAT_DEBOUNCE_MS = 20 * 1000; + +// Jitter range: ±5 seconds to prevent synchronized heartbeats across tabs +const HEARTBEAT_JITTER_MS = 5 * 1000; // Session ID key in localStorage const SESSION_ID_KEY = "markdown_blog_session_id"; +// Geo data interface from Netlify edge function +interface GeoData { + city?: string; + country?: string; + latitude?: number; + longitude?: number; +} + /** * Generate a random session ID (UUID v4 format) */ @@ -55,6 +66,7 @@ function getPageType(path: string): string { /** * Hook to track page views and maintain active session presence + * Fetches geo location from Netlify edge function for visitor map */ export function usePageTracking(): void { const location = useLocation(); @@ -70,9 +82,47 @@ export function usePageTracking(): void { const lastHeartbeatTime = useRef(0); const lastHeartbeatPath = useRef(null); - // Initialize session ID + // Geo data ref (fetched once on mount) + const geoDataRef = useRef(null); + const geoFetchedRef = useRef(false); + + // Initialize session ID and fetch geo data once on mount useEffect(() => { sessionIdRef.current = getSessionId(); + + // Fetch geo data once (skip if already fetched) + if (!geoFetchedRef.current) { + geoFetchedRef.current = true; + + // Check if running on localhost (edge functions don't work locally) + const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"; + + if (isLocalhost) { + // Use mock geo data for localhost testing + // This allows the visitor map to work during local development + geoDataRef.current = { + city: "San Francisco", + country: "US", + latitude: 37.7749, + longitude: -122.4194, + }; + } else { + // Fetch real geo data from Netlify edge function in production + fetch("/api/geo") + .then((res) => res.json()) + .then((data: GeoData) => { + // Only store if we have valid coordinates + if (data.latitude && data.longitude) { + geoDataRef.current = data; + } + }) + .catch(() => { + // Silently fail - geo data is optional + }); + } + } }, []); // Debounced heartbeat function to prevent write conflicts @@ -101,9 +151,15 @@ export function usePageTracking(): void { lastHeartbeatPath.current = path; try { + // Include geo data if available + const geo = geoDataRef.current; await heartbeatMutation({ sessionId, currentPath: path, + ...(geo?.city && { city: geo.city }), + ...(geo?.country && { country: geo.country }), + ...(geo?.latitude && { latitude: geo.latitude }), + ...(geo?.longitude && { longitude: geo.longitude }), }); } catch { // Silently fail - analytics shouldn't break the app @@ -139,17 +195,35 @@ export function usePageTracking(): void { useEffect(() => { const path = location.pathname; - // Send initial heartbeat for this path - sendHeartbeat(path); + // Add random jitter to initial delay to prevent synchronized heartbeats across tabs + const initialJitter = Math.random() * HEARTBEAT_JITTER_MS; - // Set up interval for ongoing heartbeats - const intervalId = setInterval(() => { + // Send initial heartbeat after jitter delay + const initialTimeoutId = setTimeout(() => { sendHeartbeat(path); - }, HEARTBEAT_INTERVAL_MS); + }, initialJitter); + + // Set up interval for ongoing heartbeats with jitter + // Using recursive setTimeout instead of setInterval for variable timing + let timeoutId: ReturnType; + const scheduleNextHeartbeat = () => { + const jitter = (Math.random() - 0.5) * 2 * HEARTBEAT_JITTER_MS; // ±5 seconds + const nextDelay = HEARTBEAT_INTERVAL_MS + jitter; + timeoutId = setTimeout(() => { + sendHeartbeat(path); + scheduleNextHeartbeat(); + }, nextDelay); + }; + + // Start the heartbeat loop after initial heartbeat + const loopTimeoutId = setTimeout(() => { + scheduleNextHeartbeat(); + }, initialJitter + HEARTBEAT_INTERVAL_MS); return () => { - clearInterval(intervalId); + clearTimeout(initialTimeoutId); + clearTimeout(loopTimeoutId); + clearTimeout(timeoutId); }; }, [location.pathname, sendHeartbeat]); } - diff --git a/src/pages/Stats.tsx b/src/pages/Stats.tsx index 0383769..c572478 100644 --- a/src/pages/Stats.tsx +++ b/src/pages/Stats.tsx @@ -11,6 +11,8 @@ import { Activity, } from "lucide-react"; import { GithubLogo } from "@phosphor-icons/react"; +import VisitorMap from "../components/VisitorMap"; +import siteConfig from "../config/siteConfig"; // Site launched Dec 14, 2025 at 1:00 PM (v1.0.0), stats added same day (v1.2.0) const SITE_LAUNCH_DATE = "Dec 14, 2025 at 1:00 PM"; @@ -141,6 +143,14 @@ export default function Stats() { })} + {/* Visitor map showing real-time locations */} + {siteConfig.visitorMap.enabled && stats.visitorLocations.length > 0 && ( + + )} + {/* Active visitors by page */} {stats.activeByPath.length > 0 && (
diff --git a/src/styles/global.css b/src/styles/global.css index d451ff6..2f52158 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2648,6 +2648,181 @@ body { } } +/* ============================================ + VISITOR MAP STYLES + ============================================ + World map showing real-time visitor locations + Uses Netlify geo data, privacy friendly (no IP stored) + ============================================ */ + +/* Visitor map theme colors */ +:root[data-theme="dark"] { + --visitor-map-bg: #141414; + --visitor-map-land: #4a4a4a; + --visitor-map-dot: #60a5fa; + --visitor-map-dot-core: #93c5fd; +} + +:root[data-theme="light"] { + --visitor-map-bg: #fafafa; + --visitor-map-land: #9ca3af; + --visitor-map-dot: #3b82f6; + --visitor-map-dot-core: #60a5fa; +} + +:root[data-theme="tan"] { + --visitor-map-bg: #f3f1ed; + --visitor-map-land: #b8a892; + --visitor-map-dot: #8b7355; + --visitor-map-dot-core: #b5986d; +} + +:root[data-theme="cloud"] { + --visitor-map-bg: #f0f4f8; + --visitor-map-land: #94a3b8; + --visitor-map-dot: #4682b4; + --visitor-map-dot-core: #7ba3c9; +} + +/* Visitor map container */ +.visitor-map-container { + margin-bottom: 32px; +} + +.visitor-map-title { + font-size: var(--font-size-stats-section-title); + color: var(--text-primary); + margin-bottom: 16px; + font-weight: 600; +} + +.visitor-map-wrapper { + position: relative; + border-radius: 12px; + overflow: hidden; + background: var(--visitor-map-bg); + border: 1px solid var(--border-color); +} + +.visitor-map-svg { + display: block; + width: 100%; + height: auto; + max-height: 300px; +} + +/* Pulse animation for visitor dots */ +@keyframes visitor-pulse { + 0% { + r: 5; + opacity: 0.6; + } + 50% { + r: 18; + opacity: 0; + } + 100% { + r: 5; + opacity: 0; + } +} + +@keyframes visitor-pulse-mid { + 0% { + r: 5; + opacity: 0.3; + } + 50% { + r: 12; + opacity: 0; + } + 100% { + r: 5; + opacity: 0; + } +} + +.visitor-pulse-ring { + animation: visitor-pulse 2.5s ease-out infinite; +} + +.visitor-pulse-ring-mid { + animation: visitor-pulse-mid 2.5s ease-out infinite; + animation-delay: 0.3s; +} + +.visitor-dot-center { + filter: drop-shadow(0 0 4px var(--visitor-map-dot)); +} + +/* Visitor count badge */ +.visitor-map-badge { + position: absolute; + bottom: 12px; + left: 12px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.visitor-map-badge-dot { + width: 8px; + height: 8px; + background: var(--visitor-map-dot); + border-radius: 50%; + animation: badge-pulse 2s ease-in-out infinite; +} + +@keyframes badge-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* No box-shadow on visitor map wrapper - clean flat design */ + +/* Responsive adjustments */ +@media (max-width: 768px) { + .visitor-map-container { + margin-bottom: 24px; + } + + .visitor-map-svg { + max-height: 200px; + } + + .visitor-map-badge { + font-size: var(--font-size-xs); + padding: 4px 10px; + gap: 6px; + } + + .visitor-map-badge-dot { + width: 6px; + height: 6px; + } +} + +@media (max-width: 480px) { + .visitor-map-svg { + max-height: 160px; + } + + .visitor-map-badge { + bottom: 8px; + left: 8px; + } +} + /* Logo marquee container */ .logo-marquee-container { margin: 48px 0;