feat: add real-time stats page with live visitor tracking and page view analytics

This commit is contained in:
Wayne Sutton
2025-12-14 23:07:11 -08:00
parent ffef8d6532
commit 6c10829b1c
15 changed files with 821 additions and 19 deletions

View File

@@ -11,6 +11,7 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
- Four theme options: Dark, Light, Tan (default), Cloud
- Real-time data with Convex
- Fully responsive design
- Real-time analytics at `/stats`
### SEO and Discovery
@@ -264,10 +265,29 @@ markdown-site/
- lucide-react
- Netlify
## Real-time Stats
The `/stats` page shows real-time analytics powered by Convex:
- **Active visitors**: Current visitors on the site with per-page breakdown
- **Total page views**: All-time view count
- **Unique visitors**: Based on anonymous session IDs
- **Views by page**: List of all pages sorted by view count
Stats update automatically via Convex subscriptions. No page refresh needed.
How it works:
- Page views are recorded as event records (not counters) to avoid write conflicts
- Active sessions use heartbeat presence (30s interval, 2min timeout)
- A cron job cleans up stale sessions every 5 minutes
- No PII stored (only anonymous session UUIDs)
## API Endpoints
| Endpoint | Description |
| ------------------------------ | ------------------------------- |
| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with post descriptions |
| `/rss-full.xml` | RSS feed with full post content |
| `/sitemap.xml` | Dynamic XML sitemap |

View File

@@ -2,7 +2,7 @@
## Current Status
v1.1.0 ready for deployment. Build passes. TypeScript verified.
v1.2.0 ready for deployment. Build passes. TypeScript verified.
## Completed
@@ -28,6 +28,11 @@ v1.1.0 ready for deployment. Build passes. TypeScript verified.
- [x] Mobile responsive design
- [x] Edge functions for dynamic Convex HTTP proxying
- [x] Vite dev server proxy for local development
- [x] Real-time stats page at /stats
- [x] Page view tracking with event records pattern
- [x] Active session heartbeat system
- [x] Cron job for stale session cleanup
- [x] Stats link in homepage footer
## Deployment Steps
@@ -39,7 +44,6 @@ v1.1.0 ready for deployment. Build passes. TypeScript verified.
## Future Enhancements
- [ ] Search functionality
- [ ] Post view counter display
- [ ] Related posts suggestions
- [ ] Newsletter signup
- [ ] Comments system

View File

@@ -4,6 +4,27 @@ 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.2.0] - 2025-12-14
### Added
- Real-time stats page at `/stats` with live visitor tracking
- Active visitors count with per-page breakdown
- Total page views and unique visitors
- Views by page sorted by popularity
- Page view tracking via event records pattern (no write conflicts)
- Active session heartbeat system (30s interval, 2min timeout)
- Cron job for stale session cleanup every 5 minutes
- New Convex tables: `pageViews` and `activeSessions`
- Stats link in homepage footer
### Technical
- Uses anonymous session UUIDs (no PII stored)
- All stats update in real-time via Convex subscriptions
- Mobile responsive stats grid (4 to 2 to 1 columns)
- Theme support with CSS variables (dark, light, tan, cloud)
## [1.1.0] - 2025-12-14
### Added

View File

@@ -463,12 +463,32 @@ Edit `index.html` to update:
Edit `public/llms.txt` and `public/robots.txt` with your site information.
## Real-time Stats
Your blog includes a real-time analytics page at `/stats`:
- **Active visitors**: See who is currently on your site and which pages they are viewing
- **Total page views**: All-time view count across the site
- **Unique visitors**: Count based on anonymous session IDs
- **Views by page**: Every page and post ranked by view count
Stats update automatically without refreshing. Powered by Convex subscriptions.
How it works:
- Page views are recorded as event records (not counters) to prevent write conflicts
- Active sessions use a heartbeat system (30 second interval)
- Sessions expire after 2 minutes of inactivity
- A cron job cleans up stale sessions every 5 minutes
- No personal data is stored (only anonymous UUIDs)
## API Endpoints
Your blog includes these API endpoints for search engines and AI:
| Endpoint | Description |
| ------------------------------ | --------------------------- |
| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content |
| `/sitemap.xml` | Dynamic XML sitemap |

View File

@@ -7,7 +7,7 @@ order: 0
Reference documentation for setting up, customizing, and deploying this markdown site.
**How publishing works:** Write posts in markdown, run `npm run sync`, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
## Quick start
@@ -16,7 +16,8 @@ git clone https://github.com/waynesutton/markdown-site.git
cd markdown-site
npm install
npx convex dev
npm run sync
npm run sync # development
npm run sync:prod # production
npm run dev
```
@@ -178,10 +179,22 @@ body {
| Default OG image | `public/images/og-default.svg` | 1200x630 |
| Post images | `public/images/` | Any |
## Real-time stats
The `/stats` page displays real-time analytics:
- Active visitors (with per-page breakdown)
- Total page views
- Unique visitors
- Views by page (sorted by count)
All stats update automatically via Convex subscriptions.
## API endpoints
| Endpoint | Description |
| ------------------------------ | ----------------------- |
| `/stats` | Real-time analytics |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |
@@ -250,7 +263,8 @@ export default defineSchema({
**Posts not appearing**
- Check `published: true` in frontmatter
- Run `npm run sync`
- Run `npm run sync` for development
- Run `npm run sync:prod` for production
- Verify in Convex dashboard
**RSS/Sitemap errors**

View File

@@ -8,10 +8,12 @@
* @module
*/
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
import type * as stats from "../stats.js";
import type {
ApiFromModules,
@@ -20,10 +22,12 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
crons: typeof crons;
http: typeof http;
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;
stats: typeof stats;
}>;
/**

15
convex/crons.ts Normal file
View File

@@ -0,0 +1,15 @@
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// Clean up stale sessions every 5 minutes
crons.interval(
"cleanup stale sessions",
{ minutes: 5 },
internal.stats.cleanupStaleSessions,
{}
);
export default crons;

View File

@@ -50,4 +50,24 @@ export default defineSchema({
key: v.string(),
value: v.any(),
}).index("by_key", ["key"]),
// Page view events for analytics (event records pattern)
pageViews: defineTable({
path: v.string(),
pageType: v.string(), // "blog" | "page" | "home" | "stats"
sessionId: v.string(),
timestamp: v.number(),
})
.index("by_path", ["path"])
.index("by_timestamp", ["timestamp"])
.index("by_session_path", ["sessionId", "path"]),
// Active sessions for real-time visitor tracking
activeSessions: defineTable({
sessionId: v.string(),
currentPath: v.string(),
lastSeen: v.number(),
})
.index("by_sessionId", ["sessionId"])
.index("by_lastSeen", ["lastSeen"]),
});

227
convex/stats.ts Normal file
View File

@@ -0,0 +1,227 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
// Deduplication window: 30 minutes in milliseconds
const DEDUP_WINDOW_MS = 30 * 60 * 1000;
// Session timeout: 2 minutes in milliseconds
const SESSION_TIMEOUT_MS = 2 * 60 * 1000;
/**
* Record a page view event.
* Idempotent: same session viewing same path within 30min = 1 view.
*/
export const recordPageView = mutation({
args: {
path: v.string(),
pageType: v.string(),
sessionId: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const dedupCutoff = now - DEDUP_WINDOW_MS;
// Check for recent view from same session on same path
const recentView = await ctx.db
.query("pageViews")
.withIndex("by_session_path", (q) =>
q.eq("sessionId", args.sessionId).eq("path", args.path)
)
.order("desc")
.first();
// Early return if already viewed within dedup window
if (recentView && recentView.timestamp > dedupCutoff) {
return null;
}
// Insert new view event
await ctx.db.insert("pageViews", {
path: args.path,
pageType: args.pageType,
sessionId: args.sessionId,
timestamp: now,
});
return null;
},
});
/**
* Update active session heartbeat.
* Creates or updates session with current path and timestamp.
*/
export const heartbeat = mutation({
args: {
sessionId: v.string(),
currentPath: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
// Find existing session by sessionId
const existingSession = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existingSession) {
// Update existing session
await ctx.db.patch(existingSession._id, {
currentPath: args.currentPath,
lastSeen: now,
});
} else {
// Create new session
await ctx.db.insert("activeSessions", {
sessionId: args.sessionId,
currentPath: args.currentPath,
lastSeen: now,
});
}
return null;
},
});
/**
* Get all stats for the stats page.
* Real-time subscription via useQuery.
*/
export const getStats = query({
args: {},
returns: v.object({
activeVisitors: v.number(),
activeByPath: v.array(
v.object({
path: v.string(),
count: v.number(),
})
),
totalPageViews: v.number(),
uniqueVisitors: v.number(),
publishedPosts: v.number(),
publishedPages: v.number(),
pageStats: v.array(
v.object({
path: v.string(),
title: v.string(),
pageType: v.string(),
views: v.number(),
})
),
}),
handler: async (ctx) => {
const now = Date.now();
const sessionCutoff = now - SESSION_TIMEOUT_MS;
// Get active sessions (heartbeat within last 2 minutes)
const activeSessions = await ctx.db
.query("activeSessions")
.withIndex("by_lastSeen", (q) => q.gt("lastSeen", sessionCutoff))
.collect();
// Count active visitors by path
const activeByPathMap: Record<string, number> = {};
for (const session of activeSessions) {
activeByPathMap[session.currentPath] =
(activeByPathMap[session.currentPath] || 0) + 1;
}
const activeByPath = Object.entries(activeByPathMap)
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count);
// Get all page views
const allViews = await ctx.db.query("pageViews").collect();
// Aggregate views by path and count unique sessions
const viewsByPath: Record<string, number> = {};
const uniqueSessions = new Set<string>();
for (const view of allViews) {
viewsByPath[view.path] = (viewsByPath[view.path] || 0) + 1;
uniqueSessions.add(view.sessionId);
}
// Get published posts and pages for titles
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
const pages = await ctx.db
.query("pages")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Build page stats array with titles
const pageStats = Object.entries(viewsByPath)
.map(([path, views]) => {
// Match path to post or page
const slug = path.startsWith("/") ? path.slice(1) : path;
const post = posts.find((p) => p.slug === slug);
const page = pages.find((p) => p.slug === slug);
let title = path;
let pageType = "other";
if (path === "/" || path === "") {
title = "Home";
pageType = "home";
} else if (path === "/stats") {
title = "Stats";
pageType = "stats";
} else if (post) {
title = post.title;
pageType = "blog";
} else if (page) {
title = page.title;
pageType = "page";
}
return {
path,
title,
pageType,
views,
};
})
.sort((a, b) => b.views - a.views);
return {
activeVisitors: activeSessions.length,
activeByPath,
totalPageViews: allViews.length,
uniqueVisitors: uniqueSessions.size,
publishedPosts: posts.length,
publishedPages: pages.length,
pageStats,
};
},
});
/**
* Internal mutation to clean up stale sessions.
* Called by cron job every 5 minutes.
*/
export const cleanupStaleSessions = internalMutation({
args: {},
returns: v.number(),
handler: async (ctx) => {
const cutoff = Date.now() - SESSION_TIMEOUT_MS;
// Get all stale sessions
const staleSessions = await ctx.db
.query("activeSessions")
.withIndex("by_lastSeen", (q) => q.lt("lastSeen", cutoff))
.collect();
// Delete in parallel
await Promise.all(staleSessions.map((session) => ctx.db.delete(session._id)));
return staleSessions.length;
},
});

View File

@@ -28,10 +28,11 @@ A brief description of each file in the codebase.
### Pages (`src/pages/`)
| File | Description |
| ---------- | ------------------------------------------------------- |
| `Home.tsx` | Landing page with intro, featured essays, and post list |
| `Post.tsx` | Individual blog post view with JSON-LD injection |
| File | Description |
| ----------- | ------------------------------------------------------- |
| `Home.tsx` | Landing page with intro, featured essays, and post list |
| `Post.tsx` | Individual blog post view with JSON-LD injection |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats |
### Components (`src/components/`)
@@ -49,6 +50,12 @@ A brief description of each file in the codebase.
| ------------------ | ---------------------------------------------------- |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
### Hooks (`src/hooks/`)
| File | Description |
| -------------------- | --------------------------------------------- |
| `usePageTracking.ts` | Page view recording and active session heartbeat |
### Styles (`src/styles/`)
| File | Description |
@@ -57,20 +64,23 @@ A brief description of each file in the codebase.
## Convex Backend (`convex/`)
| File | Description |
| ------------------ | ------------------------------------------------- |
| `schema.ts` | Database schema (posts, pages, viewCounts tables) |
| `posts.ts` | Queries and mutations for blog posts, view counts |
| `pages.ts` | Queries and mutations for static pages |
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
| `rss.ts` | RSS feed generation (standard and full content) |
| `convex.config.ts` | Convex app configuration |
| `tsconfig.json` | Convex TypeScript configuration |
| File | Description |
| ------------------ | ------------------------------------------------------------- |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) |
| `posts.ts` | Queries and mutations for blog posts, view counts |
| `pages.ts` | Queries and mutations for static pages |
| `stats.ts` | Real-time stats queries, page view recording, session heartbeat |
| `crons.ts` | Cron job for stale session cleanup |
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
| `rss.ts` | RSS feed generation (standard and full content) |
| `convex.config.ts` | Convex app configuration |
| `tsconfig.json` | Convex TypeScript configuration |
### HTTP Endpoints (defined in `http.ts`)
| Route | Description |
| --------------- | -------------------------------------- |
| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines |

View File

@@ -1,13 +1,19 @@
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Post from "./pages/Post";
import Stats from "./pages/Stats";
import Layout from "./components/Layout";
import { usePageTracking } from "./hooks/usePageTracking";
function App() {
// Track page views and active sessions
usePageTracking();
return (
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/stats" element={<Stats />} />
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>

View File

@@ -0,0 +1,118 @@
import { useEffect, useRef } from "react";
import { useMutation } from "convex/react";
import { useLocation } from "react-router-dom";
import { api } from "../../convex/_generated/api";
// Heartbeat interval: 30 seconds
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
// Session ID key in localStorage
const SESSION_ID_KEY = "markdown_blog_session_id";
/**
* Generate a random session ID (UUID v4 format)
*/
function generateSessionId(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Get or create a persistent session ID
*/
function getSessionId(): string {
if (typeof window === "undefined") {
return generateSessionId();
}
let sessionId = localStorage.getItem(SESSION_ID_KEY);
if (!sessionId) {
sessionId = generateSessionId();
localStorage.setItem(SESSION_ID_KEY, sessionId);
}
return sessionId;
}
/**
* Determine page type from path
*/
function getPageType(path: string): string {
if (path === "/" || path === "") {
return "home";
}
if (path === "/stats") {
return "stats";
}
// Could be a blog post or static page
return "page";
}
/**
* Hook to track page views and maintain active session presence
*/
export function usePageTracking(): void {
const location = useLocation();
const recordPageView = useMutation(api.stats.recordPageView);
const heartbeat = useMutation(api.stats.heartbeat);
// Track if we've recorded view for current path
const lastRecordedPath = useRef<string | null>(null);
const sessionIdRef = useRef<string | null>(null);
// Initialize session ID
useEffect(() => {
sessionIdRef.current = getSessionId();
}, []);
// Record page view when path changes
useEffect(() => {
const path = location.pathname;
const sessionId = sessionIdRef.current;
if (!sessionId) return;
// Only record if path changed
if (lastRecordedPath.current !== path) {
lastRecordedPath.current = path;
recordPageView({
path,
pageType: getPageType(path),
sessionId,
}).catch(() => {
// Silently fail - analytics shouldn't break the app
});
}
}, [location.pathname, recordPageView]);
// Send heartbeat on interval and on path change
useEffect(() => {
const path = location.pathname;
const sessionId = sessionIdRef.current;
if (!sessionId) return;
// Send initial heartbeat
const sendHeartbeat = () => {
heartbeat({
sessionId,
currentPath: path,
}).catch(() => {
// Silently fail
});
};
sendHeartbeat();
// Set up interval for ongoing heartbeats
const intervalId = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
return () => {
clearInterval(intervalId);
};
}, [location.pathname, heartbeat]);
}

View File

@@ -109,7 +109,11 @@ export default function Home() {
>
project on GitHub
</a>{" "}
to fork and deploy your own.
to fork and deploy your own. View{" "}
<a href="/stats" className="home-text-link">
real-time site stats
</a>
.
</p>
</section>
</div>

134
src/pages/Stats.tsx Normal file
View File

@@ -0,0 +1,134 @@
import { useQuery } from "convex/react";
import { useNavigate } from "react-router-dom";
import { api } from "../../convex/_generated/api";
import {
ArrowLeft,
Users,
Eye,
FileText,
BookOpen,
Activity,
} from "lucide-react";
export default function Stats() {
const navigate = useNavigate();
const stats = useQuery(api.stats.getStats);
// Don't render until stats load
if (stats === undefined) {
return null;
}
return (
<div className="stats-page">
{/* Header with back button */}
<nav className="stats-nav">
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
</nav>
{/* Page header */}
<header className="stats-header">
<h1 className="stats-title">Site Statistics</h1>
<p className="stats-subtitle">
Real-time analytics for this site. All data updates automatically.
</p>
</header>
{/* Stats cards grid */}
<section className="stats-grid">
{/* Active visitors card */}
<div className="stat-card">
<div className="stat-card-header">
<Activity size={18} className="stat-card-icon" />
<span className="stat-card-label">Active Now</span>
</div>
<div className="stat-card-value">{stats.activeVisitors}</div>
<div className="stat-card-desc">Visitors on site</div>
</div>
{/* Total page views card */}
<div className="stat-card">
<div className="stat-card-header">
<Eye size={18} className="stat-card-icon" />
<span className="stat-card-label">Total Views</span>
</div>
<div className="stat-card-value">{stats.totalPageViews}</div>
<div className="stat-card-desc">All-time page views</div>
</div>
{/* Unique visitors card */}
<div className="stat-card">
<div className="stat-card-header">
<Users size={18} className="stat-card-icon" />
<span className="stat-card-label">Unique Visitors</span>
</div>
<div className="stat-card-value">{stats.uniqueVisitors}</div>
<div className="stat-card-desc">Unique sessions</div>
</div>
{/* Published posts card */}
<div className="stat-card">
<div className="stat-card-header">
<BookOpen size={18} className="stat-card-icon" />
<span className="stat-card-label">Blog Posts</span>
</div>
<div className="stat-card-value">{stats.publishedPosts}</div>
<div className="stat-card-desc">Published posts</div>
</div>
{/* Published pages card */}
<div className="stat-card">
<div className="stat-card-header">
<FileText size={18} className="stat-card-icon" />
<span className="stat-card-label">Pages</span>
</div>
<div className="stat-card-value">{stats.publishedPages}</div>
<div className="stat-card-desc">Static pages</div>
</div>
</section>
{/* Active visitors by page */}
{stats.activeByPath.length > 0 && (
<section className="stats-section">
<h2 className="stats-section-title">Currently Viewing</h2>
<div className="stats-list">
{stats.activeByPath.map((item) => (
<div key={item.path} className="stats-list-item">
<span className="stats-list-path">
{item.path === "/" ? "Home" : item.path}
</span>
<span className="stats-list-count">
{item.count} {item.count === 1 ? "visitor" : "visitors"}
</span>
</div>
))}
</div>
</section>
)}
{/* Page views by page */}
{stats.pageStats.length > 0 && (
<section className="stats-section">
<h2 className="stats-section-title">Views by Page</h2>
<div className="stats-list">
{stats.pageStats.map((item) => (
<div key={item.path} className="stats-list-item">
<div className="stats-list-info">
<span className="stats-list-title">{item.title}</span>
<span className="stats-list-type">{item.pageType}</span>
</div>
<span className="stats-list-count">
{item.views} {item.views === 1 ? "view" : "views"}
</span>
</div>
))}
</div>
</section>
)}
</div>
);
}

View File

@@ -945,3 +945,188 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Stats page styles */
.stats-page {
padding-top: 20px;
}
.stats-nav {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 40px;
}
.stats-header {
margin-bottom: 40px;
}
.stats-title {
font-size: 32px;
font-weight: 400;
margin-bottom: 12px;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.stats-subtitle {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.7;
}
/* Stats cards grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 48px;
}
.stat-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
transition: background-color 0.2s ease;
}
.stat-card:hover {
background-color: var(--bg-hover);
}
.stat-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.stat-card-icon {
color: var(--text-muted);
}
.stat-card-label {
font-size: 14px;
color: var(--text-muted);
}
.stat-card-value {
font-size: 32px;
font-weight: 400;
color: var(--text-primary);
margin-bottom: 4px;
letter-spacing: -0.02em;
}
.stat-card-desc {
font-size: 13px;
color: var(--text-secondary);
}
/* Stats sections */
.stats-section {
margin-bottom: 40px;
}
.stats-section-title {
font-size: 18px;
font-weight: 400;
color: var(--text-primary);
margin-bottom: 16px;
letter-spacing: -0.01em;
}
/* Stats list */
.stats-list {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.stats-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
transition: background-color 0.15s ease;
}
.stats-list-item:hover {
background-color: var(--bg-hover);
}
.stats-list-item:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.stats-list-info {
display: flex;
align-items: center;
gap: 12px;
}
.stats-list-path,
.stats-list-title {
font-size: 15px;
color: var(--text-primary);
}
.stats-list-type {
font-size: 12px;
color: var(--text-muted);
background-color: var(--bg-hover);
padding: 2px 8px;
border-radius: 10px;
}
.stats-list-count {
font-size: 14px;
color: var(--text-secondary);
}
/* Stats responsive styles */
@media (max-width: 900px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-title {
font-size: 28px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-card {
padding: 16px;
}
.stat-card-value {
font-size: 28px;
}
.stats-list-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.stats-list-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}