@@ -5,17 +5,7 @@ import RepoCard from "@/components/RepoCard";
55import TrendChart from "@/components/TrendChart" ;
66import SnippetModal from "@/components/SnippetModal" ;
77import type { RepoReport } from "@/lib/scorer" ;
8-
9- interface WatchEntry { slug : string ; score : number ; description : string | null ; language : string | null ; savedAt : string ; }
10-
11- const STORAGE_KEY = "devlens_watchlist" ;
12-
13- function loadWatchlist ( ) : WatchEntry [ ] {
14- try { return JSON . parse ( localStorage . getItem ( STORAGE_KEY ) ?? "[]" ) ; } catch { return [ ] ; }
15- }
16- function saveWatchlist ( list : WatchEntry [ ] ) {
17- localStorage . setItem ( STORAGE_KEY , JSON . stringify ( list ) ) ;
18- }
8+ import type { WatchEntry } from "@/app/api/watchlist/route" ;
199
2010export default function Home ( ) {
2111 const [ input , setInput ] = useState ( "" ) ;
@@ -25,12 +15,20 @@ export default function Home() {
2515 const [ error , setError ] = useState ( "" ) ;
2616 const [ showSnippet , setShowSnippet ] = useState ( false ) ;
2717 const [ watchlist , setWatchlist ] = useState < WatchEntry [ ] > ( [ ] ) ;
18+ const [ watchLoading , setWatchLoading ] = useState ( true ) ;
2819
29- useEffect ( ( ) => { setWatchlist ( loadWatchlist ( ) ) ; } , [ ] ) ;
20+ // Load global watchlist on mount
21+ useEffect ( ( ) => {
22+ fetch ( "/api/watchlist" )
23+ . then ( r => r . json ( ) )
24+ . then ( d => setWatchlist ( d . list ?? [ ] ) )
25+ . catch ( ( ) => { } )
26+ . finally ( ( ) => setWatchLoading ( false ) ) ;
27+ } , [ ] ) ;
3028
3129 async function analyze ( e : React . FormEvent | null , slug ?: string ) {
3230 if ( e ) e . preventDefault ( ) ;
33- const target = slug ?? input . trim ( ) . replace ( "https://github.com/" , "" ) . replace ( / \/ + $ / , "" ) ;
31+ const target = slug ?? input . trim ( ) . replace ( "https://github.com/" , "" ) . replace ( / \/ + $ / , "" ) ;
3432 if ( ! target ) return ;
3533 setLoading ( true ) ; setError ( "" ) ; setReport ( null ) ; setHistory ( [ ] ) ;
3634 try {
@@ -43,18 +41,32 @@ export default function Home() {
4341 setReport ( data ) ;
4442 const hData = await histRes . json ( ) ;
4543 if ( histRes . ok ) setHistory ( hData . history ?? [ ] ) ;
46- const entry : WatchEntry = { slug : target , score : data . health_score , description : data . description , language : data . language , savedAt : new Date ( ) . toISOString ( ) } ;
47- const updated = [ entry , ...loadWatchlist ( ) . filter ( w => w . slug !== target ) ] ;
48- saveWatchlist ( updated ) ;
49- setWatchlist ( updated ) ;
44+
45+ // Save to global KV watchlist
46+ const entry : WatchEntry = {
47+ slug : target ,
48+ score : data . health_score ,
49+ description : data . description ,
50+ language : data . language ,
51+ savedAt : new Date ( ) . toISOString ( ) ,
52+ } ;
53+ await fetch ( "/api/watchlist" , {
54+ method : "POST" ,
55+ headers : { "Content-Type" : "application/json" } ,
56+ body : JSON . stringify ( entry ) ,
57+ } ) ;
58+ // Refresh watchlist from server
59+ const wRes = await fetch ( "/api/watchlist" ) ;
60+ const wData = await wRes . json ( ) ;
61+ setWatchlist ( wData . list ?? [ ] ) ;
5062 } catch { setError ( "Network error. Please try again." ) ; }
5163 finally { setLoading ( false ) ; }
5264 }
5365
54- function removeFromWatchlist ( slug : string ) {
55- const updated = loadWatchlist ( ) . filter ( w => w . slug !== slug ) ;
56- saveWatchlist ( updated ) ;
57- setWatchlist ( updated ) ;
66+ async function removeFromWatchlist ( slug : string ) {
67+ // Optimistic UI update
68+ setWatchlist ( prev => prev . filter ( w => w . slug !== slug ) ) ;
69+ await fetch ( `/api/watchlist?slug= ${ encodeURIComponent ( slug ) } ` , { method : "DELETE" } ) ;
5870 }
5971
6072 const scoreColor = ( s : number ) => s >= 80 ? "var(--success)" : s >= 50 ? "var(--warning)" : "var(--danger)" ;
@@ -64,77 +76,83 @@ export default function Home() {
6476 { showSnippet && report && < SnippetModal repo = { report . repo } onClose = { ( ) => setShowSnippet ( false ) } /> }
6577
6678 { /* Hero */ }
67- < section style = { { padding :"clamp(3rem,8vw,6rem) var(--space-6)" , textAlign :"center" } } >
68- < div style = { { maxWidth :"680px" , margin :"0 auto" , display :"flex" , flexDirection :"column" , gap :"var(--space-6)" } } >
69- < div style = { { display :"flex" , flexDirection :"column" , gap :"var(--space-3)" } } >
70- < h1 style = { { fontFamily :"var(--font-display)" , fontSize :"var(--text-2xl)" , fontWeight :800 , color :"var(--text)" , lineHeight :1.1 } } >
71- Repo Health.< br /> < span style = { { color :"var(--primary)" } } > In 7 Dimensions.</ span >
79+ < section style = { { padding : "clamp(3rem,8vw,6rem) var(--space-6)" , textAlign : "center" } } >
80+ < div style = { { maxWidth : "680px" , margin : "0 auto" , display : "flex" , flexDirection : "column" , gap : "var(--space-6)" } } >
81+ < div style = { { display : "flex" , flexDirection : "column" , gap : "var(--space-3)" } } >
82+ < h1 style = { { fontFamily : "var(--font-display)" , fontSize : "var(--text-2xl)" , fontWeight : 800 , color : "var(--text)" , lineHeight : 1.1 } } >
83+ Repo Health.< br /> < span style = { { color : "var(--primary)" } } > In 7 Dimensions.</ span >
7284 </ h1 >
73- < p style = { { fontSize :"var(--text-base)" , color :"var(--text-muted)" , maxWidth :"480px" , margin :"0 auto" } } > Paste any public GitHub repo URL and get a live health score — free, forever.</ p >
85+ < p style = { { fontSize : "var(--text-base)" , color : "var(--text-muted)" , maxWidth : "480px" , margin : "0 auto" } } > Paste any public GitHub repo URL and get a live health score — free, forever.</ p >
7486 </ div >
75- < form onSubmit = { ( e ) => analyze ( e ) } style = { { display :"flex" , gap :"var(--space-2)" , flexWrap :"wrap" , justifyContent :"center" } } >
76- < div style = { { position :"relative" , flex :"1 1 320px" , maxWidth :"460px" } } >
77- < Search size = { 16 } style = { { position :"absolute" , left :"var(--space-3)" , top :"50%" , transform :"translateY(-50%)" , color :"var(--text-faint)" , pointerEvents :"none" } } />
78- < input value = { input } onChange = { e => setInput ( e . target . value ) } placeholder = "owner/repo or github.com/owner/repo" aria-label = "GitHub repository"
79- style = { { width :"100%" , padding :"var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + 24px)" , border :"1px solid var(--border)" , borderRadius :"var(--radius-lg)" , background :"var(--surface-2)" , color :"var(--text)" , fontSize :"var(--text-sm)" , outline :"none" } } />
87+ < form onSubmit = { ( e ) => analyze ( e ) } style = { { display : "flex" , gap : "var(--space-2)" , flexWrap : "wrap" , justifyContent : "center" } } >
88+ < div style = { { position : "relative" , flex : "1 1 320px" , maxWidth : "460px" } } >
89+ < Search size = { 16 } style = { { position : "absolute" , left : "var(--space-3)" , top : "50%" , transform : "translateY(-50%)" , color : "var(--text-faint)" , pointerEvents : "none" } } />
90+ < input value = { input } onChange = { e => setInput ( e . target . value ) } placeholder = "owner/repo or github.com/owner/repo" aria-label = "GitHub repository"
91+ style = { { width : "100%" , padding : "var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + 24px)" , border : "1px solid var(--border)" , borderRadius : "var(--radius-lg)" , background : "var(--surface-2)" , color : "var(--text)" , fontSize : "var(--text-sm)" , outline : "none" } } />
8092 </ div >
81- < button type = "submit" disabled = { loading } style = { { padding :"var(--space-3) var(--space-6)" , background :loading ? "var(--primary-hl)" : "var(--primary)" , color :"white" , borderRadius :"var(--radius-lg)" , fontWeight :600 , fontSize :"var(--text-sm)" , display :"flex" , alignItems :"center" , gap :"var(--space-2)" , flexShrink :0 } } >
82- { loading ? < > < Loader2 size = { 16 } style = { { animation :"spin 1s linear infinite" } } /> Analyzing…</ > : < > < ArrowRight size = { 16 } /> Analyze</ > }
93+ < button type = "submit" disabled = { loading } style = { { padding : "var(--space-3) var(--space-6)" , background : loading ? "var(--primary-hl)" : "var(--primary)" , color : "white" , borderRadius : "var(--radius-lg)" , fontWeight : 600 , fontSize : "var(--text-sm)" , display : "flex" , alignItems : "center" , gap : "var(--space-2)" , flexShrink : 0 } } >
94+ { loading ? < > < Loader2 size = { 16 } style = { { animation : "spin 1s linear infinite" } } /> Analyzing…</ > : < > < ArrowRight size = { 16 } /> Analyze</ > }
8395 </ button >
8496 </ form >
85- { error && < p style = { { color :"var(--error)" , fontSize :"var(--text-sm)" } } > { error } </ p > }
97+ { error && < p style = { { color : "var(--error)" , fontSize : "var(--text-sm)" } } > { error } </ p > }
8698 </ div >
8799 </ section >
88100
89101 { /* Result */ }
90102 { report && (
91- < section style = { { padding :"0 var(--space-6) var(--space-16)" , maxWidth :"780px" , margin :"0 auto" , display :"flex" , flexDirection :"column" , gap :"var(--space-6)" } } >
92- < RepoCard report = { report } onSnippet = { ( ) => setShowSnippet ( true ) } />
93- { history . length > 0 && < TrendChart data = { history } /> }
103+ < section style = { { padding : "0 var(--space-6) var(--space-16)" , maxWidth : "780px" , margin : "0 auto" , display : "flex" , flexDirection : "column" , gap : "var(--space-6)" } } >
104+ < RepoCard report = { report } onSnippet = { ( ) => setShowSnippet ( true ) } />
105+ { history . length > 0 && < TrendChart data = { history } /> }
94106 </ section >
95107 ) }
96108
97- { /* Checked Repos watchlist */ }
98- { watchlist . length > 0 && (
99- < section style = { { padding :"0 var(--space-6) var(--space-16)" , maxWidth :"780px" , margin :"0 auto" , display :"flex" , flexDirection :"column" , gap :"var(--space-4)" } } >
100- < div style = { { display :"flex" , alignItems :"center" , gap :"var(--space-2)" } } >
101- < Bookmark size = { 16 } style = { { color :"var(--primary)" } } />
102- < h2 style = { { fontFamily :"var(--font-display)" , fontSize :"var(--text-base)" , fontWeight :800 } } > Checked Repos</ h2 >
103- < span style = { { fontSize :"var(--text-xs)" , color :"var(--text-faint)" , marginLeft :"auto" } } > { watchlist . length } saved </ span >
109+ { /* Checked Repos — global, visible to all visitors */ }
110+ { ( watchlist . length > 0 || watchLoading ) && (
111+ < section style = { { padding : "0 var(--space-6) var(--space-16)" , maxWidth : "780px" , margin : "0 auto" , display : "flex" , flexDirection : "column" , gap : "var(--space-4)" } } >
112+ < div style = { { display : "flex" , alignItems : "center" , gap : "var(--space-2)" } } >
113+ < Bookmark size = { 16 } style = { { color : "var(--primary)" } } />
114+ < h2 style = { { fontFamily : "var(--font-display)" , fontSize : "var(--text-base)" , fontWeight : 800 } } > Checked Repos</ h2 >
115+ { ! watchLoading && < span style = { { fontSize : "var(--text-xs)" , color : "var(--text-faint)" , marginLeft : "auto" } } > { watchlist . length } repos </ span > }
104116 </ div >
105- < div style = { { display :"flex" , flexDirection :"column" , gap :"var(--space-2)" } } >
106- { watchlist . map ( w => (
107- < div key = { w . slug } style = { { display :"flex" , alignItems :"center" , gap :"var(--space-3)" , background :"var(--surface-off)" , border :"1px solid var(--border)" , borderRadius :"var(--radius-lg)" , padding :"var(--space-3) var(--space-4)" , cursor :"pointer" , transition :"background .15s" } }
108- onClick = { ( ) => { setInput ( w . slug ) ; analyze ( null , w . slug ) ; } } >
109- < span style = { { fontWeight :800 , fontSize :"var(--text-sm)" , color :scoreColor ( w . score ) , minWidth :"36px" , textAlign :"center" } } > { w . score } </ span >
110- < div style = { { flex :1 , overflow :"hidden" } } >
111- < p style = { { fontWeight :600 , fontSize :"var(--text-sm)" , whiteSpace :"nowrap" , overflow :"hidden" , textOverflow :"ellipsis" } } > { w . slug } </ p >
112- { w . description && < p style = { { fontSize :"var(--text-xs)" , color :"var(--text-muted)" , whiteSpace :"nowrap" , overflow :"hidden" , textOverflow :"ellipsis" } } > { w . description } </ p > }
117+ { watchLoading ? (
118+ < div style = { { display : "flex" , alignItems : "center" , gap : "var(--space-2)" , color : "var(--text-faint)" , fontSize : "var(--text-sm)" } } >
119+ < Loader2 size = { 14 } style = { { animation : "spin 1s linear infinite" } } /> Loading…
120+ </ div >
121+ ) : (
122+ < div style = { { display : "flex" , flexDirection : "column" , gap : "var(--space-2)" } } >
123+ { watchlist . map ( w => (
124+ < div key = { w . slug } style = { { display : "flex" , alignItems : "center" , gap : "var(--space-3)" , background : "var(--surface-off)" , border : "1px solid var(--border)" , borderRadius : "var(--radius-lg)" , padding : "var(--space-3) var(--space-4)" , cursor : "pointer" , transition : "background .15s" } }
125+ onClick = { ( ) => { setInput ( w . slug ) ; analyze ( null , w . slug ) ; } } >
126+ < span style = { { fontWeight : 800 , fontSize : "var(--text-sm)" , color : scoreColor ( w . score ) , minWidth : "36px" , textAlign : "center" } } > { w . score } </ span >
127+ < div style = { { flex : 1 , overflow : "hidden" } } >
128+ < p style = { { fontWeight : 600 , fontSize : "var(--text-sm)" , whiteSpace : "nowrap" , overflow : "hidden" , textOverflow : "ellipsis" } } > { w . slug } </ p >
129+ { w . description && < p style = { { fontSize : "var(--text-xs)" , color : "var(--text-muted)" , whiteSpace : "nowrap" , overflow : "hidden" , textOverflow : "ellipsis" } } > { w . description } </ p > }
130+ </ div >
131+ { w . language && < span style = { { fontSize : "var(--text-xs)" , color : "var(--text-faint)" , flexShrink : 0 } } > { w . language } </ span > }
132+ < a href = { `/repo/${ w . slug } ` } onClick = { e => e . stopPropagation ( ) } target = "_blank" rel = "noopener noreferrer"
133+ style = { { color : "var(--text-faint)" , flexShrink : 0 , display : "flex" } } title = "Open permalink" >
134+ < ExternalLink size = { 14 } />
135+ </ a >
136+ < button onClick = { e => { e . stopPropagation ( ) ; removeFromWatchlist ( w . slug ) ; } }
137+ style = { { color : "var(--text-faint)" , flexShrink : 0 , display : "flex" , background : "none" , border : "none" , cursor : "pointer" , padding : 0 } } title = "Remove" >
138+ < X size = { 14 } />
139+ </ button >
113140 </ div >
114- { w . language && < span style = { { fontSize :"var(--text-xs)" , color :"var(--text-faint)" , flexShrink :0 } } > { w . language } </ span > }
115- < a href = { `/repo/${ w . slug } ` } onClick = { e => e . stopPropagation ( ) } target = "_blank" rel = "noopener noreferrer"
116- style = { { color :"var(--text-faint)" , flexShrink :0 , display :"flex" } } title = "Open permalink" >
117- < ExternalLink size = { 14 } />
118- </ a >
119- < button onClick = { e => { e . stopPropagation ( ) ; removeFromWatchlist ( w . slug ) ; } }
120- style = { { color :"var(--text-faint)" , flexShrink :0 , display :"flex" , background :"none" , border :"none" , cursor :"pointer" , padding :0 } } title = "Remove" >
121- < X size = { 14 } />
122- </ button >
123- </ div >
124- ) ) }
125- </ div >
141+ ) ) }
142+ </ div >
143+ ) }
126144 </ section >
127145 ) }
128146
129147 { /* Feature grid (empty state) */ }
130- { ! report && ! loading && watchlist . length === 0 && (
131- < section style = { { padding :"0 var(--space-6) var(--space-16)" , maxWidth :"960px" , margin :"0 auto" } } >
132- < div style = { { display :"grid" , gridTemplateColumns :"repeat(auto-fill, minmax(min(260px,100%), 1fr))" , gap :"var(--space-4)" } } >
133- { [ { emoji :"📝" , title :"README Quality" , desc :"Length, sections, badges, code blocks" } , { emoji :"🔥" , title :"Commit Activity" , desc :"Push frequency over last 90 days" } , { emoji :"🌿" , title :"Repo Freshness" , desc :"Days since last push to main" } , { emoji :"📚" , title :"Documentation" , desc :"LICENSE, CONTRIBUTING, CHANGELOG, SECURITY" } , { emoji :"⚙️" , title :"CI/CD Setup" , desc :"GitHub Actions workflows detected" } , { emoji :"🎯" , title :"Issue Response" , desc :"Closed vs open issue ratio" } , { emoji :"⭐" , title :"Community Signal" , desc :"Stars, forks, watchers momentum" } ] . map ( f => (
134- < div key = { f . title } style = { { background :"var(--surface)" , border :"1px solid var(--border)" , borderRadius :"var(--radius-lg)" , padding :"var(--space-5)" , display :"flex" , flexDirection :"column" , gap :"var(--space-2)" } } >
135- < span style = { { fontSize :"1.5rem" } } > { f . emoji } </ span >
136- < span style = { { fontWeight :700 , fontSize :"var(--text-sm)" } } > { f . title } </ span >
137- < span style = { { fontSize :"var(--text-xs)" , color :"var(--text-muted)" } } > { f . desc } </ span >
148+ { ! report && ! loading && watchlist . length === 0 && ! watchLoading && (
149+ < section style = { { padding : "0 var(--space-6) var(--space-16)" , maxWidth : "960px" , margin : "0 auto" } } >
150+ < div style = { { display : "grid" , gridTemplateColumns : "repeat(auto-fill, minmax(min(260px,100%), 1fr))" , gap : "var(--space-4)" } } >
151+ { [ { emoji : "📝" , title : "README Quality" , desc : "Length, sections, badges, code blocks" } , { emoji : "🔥" , title : "Commit Activity" , desc : "Push frequency over last 90 days" } , { emoji : "🌿" , title : "Repo Freshness" , desc : "Days since last push to main" } , { emoji : "📚" , title : "Documentation" , desc : "LICENSE, CONTRIBUTING, CHANGELOG, SECURITY" } , { emoji : "⚙️" , title : "CI/CD Setup" , desc : "GitHub Actions workflows detected" } , { emoji : "🎯" , title : "Issue Response" , desc : "Closed vs open issue ratio" } , { emoji : "⭐" , title : "Community Signal" , desc : "Stars, forks, watchers momentum" } ] . map ( f => (
152+ < div key = { f . title } style = { { background : "var(--surface)" , border : "1px solid var(--border)" , borderRadius : "var(--radius-lg)" , padding : "var(--space-5)" , display : "flex" , flexDirection : "column" , gap : "var(--space-2)" } } >
153+ < span style = { { fontSize : "1.5rem" } } > { f . emoji } </ span >
154+ < span style = { { fontWeight : 700 , fontSize : "var(--text-sm)" } } > { f . title } </ span >
155+ < span style = { { fontSize : "var(--text-xs)" , color : "var(--text-muted)" } } > { f . desc } </ span >
138156 </ div >
139157 ) ) }
140158 </ div >
@@ -143,4 +161,4 @@ export default function Home() {
143161 < style > { `@keyframes spin{to{transform:rotate(360deg)}} input:focus{border-color:var(--primary)!important;box-shadow:0 0 0 3px var(--primary-hl)}` } </ style >
144162 </ >
145163 ) ;
146- }
164+ }
0 commit comments