Skip to content

Commit b615a21

Browse files
sirjamesgrayclaude
andcommitted
feat: Add PageScore system, fix icons, sync settings menus, animate author toggle
PageScore System (admin-only page quality scoring): - Create page-scoring constants with 0-100 scale (lower = better) - Implement PageScoringService with 4 scoring factors: - External-to-internal link ratio (0-25) - Internal user links to other users' pages (0-25) - Show author links with compound links (0-25) - Backlinks received from other pages (0-25) - Add PageScoreBadge component for visual score display - Add PageScoreBreakdown modal for detailed factor breakdown - Integrate scoring into page save (versions-server.ts) - Add pageScore, pageScoreFactors fields to Page interface - Display PageScoreBadge in ActivityCard for admins - Include pageScore in activity feed API response - Create backfill script for existing pages (scripts/backfill-page-scores.ts) Icon System: - Add SlidersHorizontal icon to Icon.tsx (was showing "?" fallback) - Fix filter button icons in WhatLinksHere and RelatedPagesSection Settings Menu Sync: - Create app/constants/about-links.ts as single source of truth - Update AboutPage (desktop) and AboutContent (mobile drawer) to use shared config - Both now consistently show Design System link and all other about links Horizontal Animation for Author Toggle: - Add AnimatedHorizontalPresence to AnimatedStack.tsx - Animates width from 0 to auto for smooth horizontal expand/collapse - Apply to "by @username" in WhatLinksHere and RelatedPagesSection - Prevents layout flicker when toggling show author Design System: - Add PageLinksCardSection documenting filter button pattern - Include interactive demo with headerAction and subheader props - Register new section in design-system index 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6a614f6 commit b615a21

19 files changed

Lines changed: 2086 additions & 83 deletions

File tree

app/api/recent-edits/global/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ export async function GET(request: NextRequest) {
278278
pledgeCount: page.pledgeCount || 0,
279279
lastDiff: page.lastDiff,
280280
diffPreview: replyPreview || page.diffPreview || page.lastDiff?.preview || null,
281+
// Page quality score (admin-only visibility)
282+
pageScore: page.pageScore ?? null,
283+
pageScoreFactors: page.pageScoreFactors ?? null,
281284
source: 'pages-collection'
282285
};
283286
});

app/components/activity/ActivityCard.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { sanitizeUsername } from "../../utils/usernameSecurity";
1717
import { useAuth } from '../../providers/AuthProvider';
1818
import { isExactDateFormat } from "../../utils/dailyNoteNavigation";
1919
import DiffPreview, { DiffStats } from "./DiffPreview";
20+
import { PageScoreBadge } from "../admin/PageScoreBadge";
21+
import { PageScoreBreakdown } from "../admin/PageScoreBreakdown";
2022

2123
import { navigateToPage } from "../../utils/pagePermissions";
2224
import { useRouter } from "next/navigation";
@@ -53,6 +55,10 @@ const ActivityCard = ({ activity, isCarousel = false, compactLayout = false }) =
5355
const { isEnabled } = useFeatureFlags();
5456
const showUILabels = isEnabled('ui_labels');
5557
const [isRestoring, setIsRestoring] = useState(false);
58+
const [showPageScoreBreakdown, setShowPageScoreBreakdown] = useState(false);
59+
60+
// Check if current user is admin (for showing PageScore badge)
61+
const isAdmin = user?.isAdmin === true;
5662

5763
// Check if user can restore this version (is page owner and in activity context)
5864
const canRestore = (activity.isActivityContext || activity.isHistoryContext) &&
@@ -382,15 +388,30 @@ const ActivityCard = ({ activity, isCarousel = false, compactLayout = false }) =
382388
{sanitizeUsername(activity.username)}
383389
</span>
384390
) : (
385-
<UsernameBadge
386-
userId={activity.userId}
387-
username={activity.username || "Missing username"}
388-
tier={activity.subscriptionTier}
389-
size="sm"
390-
showBadge={true}
391-
onClick={(e) => e.stopPropagation()}
392-
className="inline-flex flex-shrink-0"
393-
/>
391+
<>
392+
<UsernameBadge
393+
userId={activity.userId}
394+
username={activity.username || "Missing username"}
395+
tier={activity.subscriptionTier}
396+
size="sm"
397+
showBadge={true}
398+
onClick={(e) => e.stopPropagation()}
399+
className="inline-flex flex-shrink-0"
400+
/>
401+
{/* PageScore badge - admin only */}
402+
{isAdmin && activity.pageScore !== undefined && activity.pageScore !== null && (
403+
<PageScoreBadge
404+
score={activity.pageScore}
405+
size="sm"
406+
showScore={true}
407+
onClick={(e) => {
408+
e.stopPropagation();
409+
setShowPageScoreBreakdown(true);
410+
}}
411+
className="ml-1"
412+
/>
413+
)}
414+
</>
394415
)}
395416
</>
396417
)}
@@ -540,6 +561,17 @@ const ActivityCard = ({ activity, isCarousel = false, compactLayout = false }) =
540561
</Tooltip>
541562
)}
542563
</TooltipProvider>
564+
565+
{/* PageScore Breakdown Dialog - admin only */}
566+
{isAdmin && (
567+
<PageScoreBreakdown
568+
isOpen={showPageScoreBreakdown}
569+
onClose={() => setShowPageScoreBreakdown(false)}
570+
pageTitle={currentPageName || 'Untitled'}
571+
pageScore={activity.pageScore}
572+
pageScoreFactors={activity.pageScoreFactors}
573+
/>
574+
)}
543575
</div>
544576
);
545577
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Page Score Badge Component
3+
*
4+
* Displays a color-coded badge indicating page quality level.
5+
* Used in admin page lists and page detail views.
6+
* Note: LOWER scores = BETTER quality (opposite of risk scoring)
7+
*
8+
* Quality Levels:
9+
* - 0-25: Green (Excellent - well-connected, community-engaged)
10+
* - 26-50: Blue (Good - some engagement)
11+
* - 51-75: Yellow (Fair - limited community connection)
12+
* - 76-100: Red (Poor - isolated or low-quality)
13+
*
14+
* @see app/services/PageScoringService.ts
15+
*/
16+
17+
'use client';
18+
19+
import React from 'react';
20+
import { Badge } from '../ui/badge';
21+
import { Icon } from '../ui/Icon';
22+
import {
23+
Tooltip,
24+
TooltipContent,
25+
TooltipProvider,
26+
TooltipTrigger,
27+
} from '../ui/tooltip';
28+
import {
29+
PAGE_SCORE_THRESHOLDS,
30+
PAGE_SCORE_LEVELS,
31+
getPageScoreLevelFromScore
32+
} from '../../constants/page-scoring';
33+
34+
export type PageScoreLevel = 'excellent' | 'good' | 'fair' | 'poor';
35+
36+
interface PageScoreBadgeProps {
37+
score: number | null | undefined;
38+
level?: PageScoreLevel;
39+
showScore?: boolean;
40+
showTooltip?: boolean;
41+
size?: 'sm' | 'md' | 'lg';
42+
onClick?: () => void;
43+
className?: string;
44+
}
45+
46+
function levelToConfig(level: PageScoreLevel): {
47+
variant: 'success-secondary' | 'default' | 'warning-secondary' | 'destructive-secondary' | 'outline-static';
48+
label: string;
49+
icon: string;
50+
description: string;
51+
} {
52+
switch (level) {
53+
case 'excellent':
54+
return {
55+
variant: 'success-secondary',
56+
label: 'Excellent',
57+
icon: 'Star',
58+
description: 'Well-connected page with strong community engagement.',
59+
};
60+
case 'good':
61+
return {
62+
variant: 'default',
63+
label: 'Good',
64+
icon: 'ThumbsUp',
65+
description: 'Page has some community engagement and connections.',
66+
};
67+
case 'fair':
68+
return {
69+
variant: 'warning-secondary',
70+
label: 'Fair',
71+
icon: 'Minus',
72+
description: 'Limited community connection. Could benefit from more internal links.',
73+
};
74+
case 'poor':
75+
return {
76+
variant: 'destructive-secondary',
77+
label: 'Poor',
78+
icon: 'AlertTriangle',
79+
description: 'Isolated page with few connections. May indicate low-quality or spam content.',
80+
};
81+
}
82+
}
83+
84+
export function PageScoreBadge({
85+
score,
86+
level: providedLevel,
87+
showScore = true,
88+
showTooltip = true,
89+
size = 'md',
90+
onClick,
91+
className = '',
92+
}: PageScoreBadgeProps) {
93+
// Handle null/undefined score
94+
if (score === null || score === undefined) {
95+
return (
96+
<Badge variant="outline-static" className={`text-muted-foreground ${className}`}>
97+
<Icon name="HelpCircle" size={size === 'sm' ? 10 : size === 'lg' ? 14 : 12} className="mr-1" />
98+
N/A
99+
</Badge>
100+
);
101+
}
102+
103+
const level = providedLevel || getPageScoreLevelFromScore(score);
104+
const config = levelToConfig(level);
105+
106+
const sizeClasses = {
107+
sm: 'text-[10px] px-1.5 py-0.5',
108+
md: 'text-xs px-2 py-0.5',
109+
lg: 'text-sm px-2.5 py-1',
110+
};
111+
112+
const iconSize = size === 'sm' ? 10 : size === 'lg' ? 14 : 12;
113+
114+
const badge = (
115+
<Badge
116+
variant={config.variant}
117+
className={`${sizeClasses[size]} ${onClick ? 'cursor-pointer hover:opacity-80' : ''} ${className}`}
118+
onClick={onClick}
119+
>
120+
<Icon name={config.icon} size={iconSize} className="mr-1" />
121+
{showScore ? `${score}` : config.label}
122+
</Badge>
123+
);
124+
125+
if (!showTooltip) {
126+
return badge;
127+
}
128+
129+
return (
130+
<TooltipProvider>
131+
<Tooltip delayDuration={300}>
132+
<TooltipTrigger asChild>
133+
{badge}
134+
</TooltipTrigger>
135+
<TooltipContent side="top" className="max-w-xs">
136+
<div className="space-y-1">
137+
<div className="font-semibold flex items-center gap-1">
138+
<Icon name={config.icon} size={14} />
139+
Quality: {config.label} ({score}/100)
140+
</div>
141+
<p className="text-xs text-muted-foreground">
142+
{config.description}
143+
</p>
144+
<p className="text-xs text-muted-foreground border-t pt-1 mt-1">
145+
Lower scores = better quality. Based on link patterns and community engagement.
146+
</p>
147+
{onClick && (
148+
<p className="text-xs text-primary">Click for detailed breakdown</p>
149+
)}
150+
</div>
151+
</TooltipContent>
152+
</Tooltip>
153+
</TooltipProvider>
154+
);
155+
}
156+
157+
/**
158+
* Inline page score indicator for compact spaces
159+
*/
160+
export function PageScoreIndicator({
161+
score,
162+
className = '',
163+
}: {
164+
score: number | null | undefined;
165+
className?: string;
166+
}) {
167+
if (score === null || score === undefined) {
168+
return <span className={`text-muted-foreground ${className}`}>-</span>;
169+
}
170+
171+
const level = getPageScoreLevelFromScore(score);
172+
173+
const colorClasses: Record<PageScoreLevel, string> = {
174+
excellent: 'text-green-600 dark:text-green-400',
175+
good: 'text-blue-600 dark:text-blue-400',
176+
fair: 'text-yellow-600 dark:text-yellow-400',
177+
poor: 'text-red-600 dark:text-red-400',
178+
};
179+
180+
return (
181+
<span className={`font-medium ${colorClasses[level]} ${className}`}>
182+
{score}
183+
</span>
184+
);
185+
}
186+
187+
export default PageScoreBadge;

0 commit comments

Comments
 (0)