From d4052f5edb220154512ef1f65f74173155feea87 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:16:04 +0000 Subject: [PATCH 1/6] feat: Add GitHub Profile Card Decoration - Rename `public/edge-functions` to `public/functions`. - Add `public/functions/api/card.js` to serve dynamic SVG profile cards. - Update `public/functions/api/user/data.js` to store `latest_5` stats in KV. - Update frontend to pre-calculate simplified route geometries and stats. - Add "GitHub Decoration" button in StatsView. --- .../api/auth/complete_github_register.js | 92 --------- public/edge-functions/api/auth/oauth.js | 174 ----------------- public/functions/api/card.js | 137 ++++++++++++++ public/functions/api/user/data.js | 70 ++++--- src/RailRound.jsx | 175 +++++++++++++++++- src/services/api.js | 4 +- 6 files changed, 341 insertions(+), 311 deletions(-) delete mode 100644 public/edge-functions/api/auth/complete_github_register.js delete mode 100644 public/edge-functions/api/auth/oauth.js create mode 100644 public/functions/api/card.js diff --git a/public/edge-functions/api/auth/complete_github_register.js b/public/edge-functions/api/auth/complete_github_register.js deleted file mode 100644 index ab59013..0000000 --- a/public/edge-functions/api/auth/complete_github_register.js +++ /dev/null @@ -1,92 +0,0 @@ -// SHA-256 Hashing helper -async function sha256(message) { - const encoder = new TextEncoder(); - const data = encoder.encode(message); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); -} - -export async function onRequest(event) { - const headers = { - "Content-Type": "application/json; charset=utf-8", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type" - }; - - if (event.request.method === "OPTIONS") { - return new Response(null, { headers }); - } - - if (event.request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers }); - } - - const DB = globalThis.RAILROUND_KV; - if (!DB) return new Response(JSON.stringify({ error: "KV Missing" }), { status: 500, headers }); - - try { - const body = await event.request.json(); - const { username, password, reg_token } = body; - - if (!username || !password || !reg_token) { - return new Response(JSON.stringify({ error: "Missing required fields" }), { status: 400, headers }); - } - - // 1. Verify Registration Token - const tempKey = `temp_reg:${reg_token}`; - const tempRaw = await DB.get(tempKey); - - if (!tempRaw) { - return new Response(JSON.stringify({ error: "Registration session expired or invalid" }), { status: 400, headers }); - } - - const githubUser = JSON.parse(tempRaw); - - // 2. Check Username Availability - const userKey = `user:${username}`; - const existing = await DB.get(userKey); - if (existing) { - return new Response(JSON.stringify({ error: "Username already exists" }), { status: 409, headers }); - } - - // 3. Create Account - const hashedPassword = await sha256(password); - - const userData = { - username, - password: hashedPassword, - created_at: new Date().toISOString(), - bindings: { - github: githubUser // Bind the GitHub info - }, - trips: [], - pins: [] - }; - - // Store User - await DB.put(userKey, JSON.stringify(userData)); - - // Store Binding - const bindingKey = `binding:github:${githubUser.id}`; - await DB.put(bindingKey, username); - - // 4. Cleanup Temp Token - await DB.delete(tempKey); - - // 5. Create Session (Auto Login) - const token = crypto.randomUUID(); - const sessionKey = `session:${token}`; - await DB.put(sessionKey, username, { expirationTtl: 86400 * 30 }); - - return new Response(JSON.stringify({ - success: true, - token, - username - }), { headers }); - - } catch (e) { - return new Response(JSON.stringify({ error: e.message }), { status: 500, headers }); - } -} diff --git a/public/edge-functions/api/auth/oauth.js b/public/edge-functions/api/auth/oauth.js deleted file mode 100644 index 525173c..0000000 --- a/public/edge-functions/api/auth/oauth.js +++ /dev/null @@ -1,174 +0,0 @@ -export async function onRequest(event) { - const headers = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - }; - - if (event.request.method === "OPTIONS") { - return new Response(null, { headers }); - } - - try { - const url = new URL(event.request.url); - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - const provider = url.searchParams.get("provider"); - const DB = globalThis.RAILROUND_KV; - - // 1. Handle Callback (if 'code' is present) - if (code) { - if (error) { - return new Response(JSON.stringify({ error }), { status: 400, headers: { "Content-Type": "application/json" } }); - } - - if (!DB) throw new Error("KV Missing"); - - const CLIENT_ID = env.CLIENT_ID; - const CLIENT_SECRET = env.CLIENT_SECRET; - - if (!CLIENT_ID || !CLIENT_SECRET) { - return new Response(JSON.stringify({ error: "Server Configuration Error" }), { status: 500, headers: { "Content-Type": "application/json" } }); - } - - // Parse State to check for Session Token (Binding Mode) - const stateParam = url.searchParams.get("state"); - let sessionToken = null; - if (stateParam) { - try { - const parsed = JSON.parse(decodeURIComponent(stateParam)); - if (parsed && parsed.t) sessionToken = parsed.t; - } catch (e) { - console.warn("Failed to parse state", e); - } - } - - // Exchange code for access token - const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: code - }) - }); - - const tokenData = await tokenResponse.json(); - if (tokenData.error) throw new Error(tokenData.error_description || "Failed to get token"); - const accessToken = tokenData.access_token; - - // Fetch User Info - const userResponse = await fetch('https://api.github.com/user', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'User-Agent': 'RailRound-EdgeFunction' - } - }); - - if (!userResponse.ok) throw new Error("Failed to fetch GitHub user"); - const githubUser = await userResponse.json(); - - const bindingKey = `binding:github:${githubUser.id}`; - - // --- Scenario A: Binding to Existing Account --- - if (sessionToken) { - // Verify Session - const sessionKey = `session:${sessionToken}`; - const currentUsername = await DB.get(sessionKey); - - if (!currentUsername) { - return Response.redirect(`${url.origin}/?error=session_expired`, 302); - } - - // Check if this GitHub ID is already bound - const existingBoundUser = await DB.get(bindingKey); - if (existingBoundUser && existingBoundUser !== currentUsername) { - return Response.redirect(`${url.origin}/?error=github_bound_to_other`, 302); - } - - // Perform Binding - const userKey = `user:${currentUsername}`; - const userDataRaw = await DB.get(userKey); - if (!userDataRaw) throw new Error("User record missing"); - - const userData = JSON.parse(userDataRaw); - userData.bindings = userData.bindings || {}; - userData.bindings.github = { - id: githubUser.id, - login: githubUser.login, - avatar_url: githubUser.avatar_url, - name: githubUser.name - }; - - await DB.put(userKey, JSON.stringify(userData)); - await DB.put(bindingKey, currentUsername); - - // Redirect home with status - return Response.redirect(`${url.origin}/?status=bound_success`, 302); - } - - // --- Scenario B: Login or Register --- - let username = await DB.get(bindingKey); - - if (username) { - // Login Flow - const token = crypto.randomUUID(); - const sessionKey = `session:${token}`; - await DB.put(sessionKey, username, { expirationTtl: 86400 * 30 }); - return Response.redirect(`${url.origin}/?token=${token}&username=${username}`, 302); - } else { - // Register Flow (Pending) - // Store GitHub info temporarily - const regToken = crypto.randomUUID(); - const tempKey = `temp_reg:${regToken}`; - const tempPayload = { - id: githubUser.id, - login: githubUser.login, - avatar_url: githubUser.avatar_url, - name: githubUser.name - }; - // Expires in 1 hour - await DB.put(tempKey, JSON.stringify(tempPayload), { expirationTtl: 3600 }); - - // Redirect to frontend completion flow - return Response.redirect(`${url.origin}/?reg_token=${regToken}`, 302); - } - } - - // 2. Handle Initiation (if 'provider' is present) - if (provider === 'github') { - const clientId = env.CLIENT_ID; - if (!clientId) { - return new Response(JSON.stringify({ error: "Server Configuration Error: Missing CLIENT_ID" }), { status: 500, headers: { "Content-Type": "application/json" } }); - } - - // Parse session_token if provided (for binding) - const sessionToken = url.searchParams.get("session_token"); - const stateObj = { - nonce: crypto.randomUUID(), - t: sessionToken || undefined - }; - // Simple JSON encoding for state - const state = encodeURIComponent(JSON.stringify(stateObj)); - - // Point back to THIS file for the callback - const redirectUri = `${url.origin}/api/auth/oauth`; - const targetUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=read:user&state=${state}`; - - return Response.redirect(targetUrl, 302); - } - - // Google Placeholder - if (provider === 'google') { - return new Response(JSON.stringify({ error: "Google Auth not implemented yet" }), { status: 501, headers: { "Content-Type": "application/json" } }); - } - - return new Response(JSON.stringify({ error: "Invalid Request" }), { status: 400, headers: { "Content-Type": "application/json" } }); - - } catch (e) { - return new Response(JSON.stringify({ error: "Auth Failed", details: e.message }), { status: 500, headers: { "Content-Type": "application/json" } }); - } -} diff --git a/public/functions/api/card.js b/public/functions/api/card.js new file mode 100644 index 0000000..5c07ae3 --- /dev/null +++ b/public/functions/api/card.js @@ -0,0 +1,137 @@ +export async function onRequest(event) { + const url = new URL(event.request.url); + const username = url.searchParams.get("user"); + + // SVG Headers + const headers = { + "Content-Type": "image/svg+xml", + "Cache-Control": "max-age=300, s-maxage=300", // Cache for 5 mins + "Access-Control-Allow-Origin": "*" + }; + + if (!username) { + return new Response('User not found', { headers }); + } + + try { + const DB = globalThis.RAILROUND_KV; + if (!DB) return new Response('KV Error', { headers }); + + const userKey = `user:${username}`; + const dataRaw = await DB.get(userKey); + + if (!dataRaw) { + return new Response('User data not found', { headers }); + } + + const data = JSON.parse(dataRaw); + const stats = data.latest_5 || { count: 0, dist: 0, lines: 0, latest: [] }; + + // --- SVG Generation --- + // Width: 800px (standard github readme width is flexible, but 800 is good for high-dpi) + // Height: Variable based on items? Let's fix to a card size. + // Layout: + // Top: Header + Summary Stats (Trips, Lines, KM) + // Body: List of 5 trips with Mini-Map + + const esc = (str) => { + if (!str) return ""; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + const cardWidth = 500; + const headerHeight = 100; + const rowHeight = 60; + const totalHeight = headerHeight + (stats.latest.length * rowHeight) + 20; + + const styles = ` + .bg { fill: #0f172a; } + .card { fill: #1e293b; rx: 12px; } + .text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; fill: #e2e8f0; } + .label { font-size: 10px; fill: #94a3b8; font-weight: bold; text-transform: uppercase; } + .value { font-size: 18px; font-weight: bold; fill: #38bdf8; } + .trip-title { font-size: 12px; font-weight: bold; fill: #f8fafc; } + .trip-date { font-size: 10px; fill: #64748b; } + .trip-dist { font-size: 10px; fill: #cbd5e1; font-family: monospace; } + .line-path { fill: none; stroke: #38bdf8; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + .grid-line { stroke: #334155; stroke-width: 1; } + `; + + let svgContent = ` + + + + + + + ${esc(username)}'s RailRound + + + Trips + ${stats.count} + + Lines + ${stats.lines} + + Distance + ${Math.round(stats.dist)}km + + + + + + + + + + + + + + `; + + stats.latest.forEach((trip, idx) => { + const y = idx * rowHeight; + const points = trip.svg_points || ""; + // Mini Map SVG (100x40 coordinate space in a 80x30 box) + + svgContent += ` + + + + + + + ${esc(trip.title)} + ${esc(trip.date)} + + + + + + + + + + + ${Math.round(trip.dist)} km + + `; + }); + + svgContent += ` + + + `; + + return new Response(svgContent, { headers }); + + } catch (e) { + return new Response(`${e.message}`, { headers }); + } +} diff --git a/public/functions/api/user/data.js b/public/functions/api/user/data.js index d3921e9..84a9ee0 100644 --- a/public/functions/api/user/data.js +++ b/public/functions/api/user/data.js @@ -1,66 +1,60 @@ export async function onRequest(event) { const headers = { - "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization" + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Content-Type": "application/json" }; if (event.request.method === "OPTIONS") { return new Response(null, { headers }); } - const DB = globalThis.RAILROUND_KV; - if (!DB) return new Response(JSON.stringify({ error: "KV Missing" }), { status: 500, headers }); - - // Auth check - const authHeader = event.request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers }); - } + try { + const DB = globalThis.RAILROUND_KV; + if (!DB) throw new Error("KV Missing"); - const token = authHeader.split(" ")[1]; - const sessionKey = `session:${token}`; + const authHeader = event.request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers }); + } + const token = authHeader.split(" ")[1]; - try { - const username = await DB.get(sessionKey); + // Verify Session + const username = await DB.get(`session:${token}`); if (!username) { - return new Response(JSON.stringify({ error: "Invalid or expired token" }), { status: 401, headers }); + return new Response(JSON.stringify({ error: "Invalid Token" }), { status: 401, headers }); } const userKey = `user:${username}`; - let rawUserData = await DB.get(userKey); - - if (!rawUserData) { - return new Response(JSON.stringify({ error: "User data corrupted" }), { status: 404, headers }); - } - - let userData = typeof rawUserData === 'string' ? JSON.parse(rawUserData) : rawUserData; if (event.request.method === "GET") { - // Return only data, remove sensitive info like password - const { password, ...safeData } = userData; - return new Response(JSON.stringify(safeData), { headers }); + const dataRaw = await DB.get(userKey); + const data = dataRaw ? JSON.parse(dataRaw) : { trips: [], pins: [] }; + // Return only what is needed for the frontend app (latest_5 is for the card api, but no harm returning it) + return new Response(JSON.stringify(data), { status: 200, headers }); } - else if (event.request.method === "POST") { - const body = await event.request.json(); - // Update trips and pins - // We do a merge strategy or overwrite? - // Plan said "Merge/Overwrite". Let's assume the client sends the full new state for simplicity and consistency with the current frontend app logic which holds full state. - - if (body.trips) userData.trips = body.trips; - if (body.pins) userData.pins = body.pins; + if (event.request.method === "POST") { + const body = await event.request.json(); + const { trips, pins, latest_5 } = body; - // Update timestamp - userData.updated_at = new Date().toISOString(); + // Fetch existing to preserve other fields (like password, bindings) + const existingRaw = await DB.get(userKey); + const existing = existingRaw ? JSON.parse(existingRaw) : {}; - await DB.put(userKey, JSON.stringify(userData)); + const newData = { + ...existing, + trips: trips || existing.trips || [], + pins: pins || existing.pins || [], + latest_5: latest_5 || existing.latest_5 || null // Store the pre-calculated card data + }; - return new Response(JSON.stringify({ success: true }), { headers }); + await DB.put(userKey, JSON.stringify(newData)); + return new Response(JSON.stringify({ success: true }), { status: 200, headers }); } - return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers }); + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405, headers }); } catch (e) { return new Response(JSON.stringify({ error: e.message }), { status: 500, headers }); diff --git a/src/RailRound.jsx b/src/RailRound.jsx index d4860c6..4e14fbc 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -1048,6 +1048,162 @@ const RouteSlice = ({ segments, segmentGeometries }) => { ); }; +// --- Refactored: Helper for Stats Calculation (Latest 5 + SVG Points) --- +const calculateLatestStats = (trips, segmentGeometries, railwayData) => { + // 1. Basic Stats + const totalTrips = trips.length; + const allSegments = trips.flatMap(t => t.segments || [{ lineKey: t.lineKey, fromId: t.fromId, toId: t.toId }]); + const uniqueLines = new Set(allSegments.map(s => s.lineKey)).size; + let totalDist = 0; + + // Calc total distance + if (turf && segmentGeometries) { + allSegments.forEach(seg => { + const key = `${seg.lineKey}_${seg.fromId}_${seg.toId}`; + const geom = segmentGeometries.get(key); + if (geom && geom.coords) { + if (geom.isMulti) { + geom.coords.forEach(c => totalDist += turf.length(turf.lineString(c.map(p => [p[1], p[0]])))); + } else { + totalDist += turf.length(turf.lineString(geom.coords.map(p => [p[1], p[0]]))); + } + } else { + // Fallback + const line = railwayData[seg.lineKey]; + if (line) { + const s1 = line.stations.find(st => st.id === seg.fromId); + const s2 = line.stations.find(st => st.id === seg.toId); + if (s1 && s2) totalDist += calcDist(s1.lat, s1.lng, s2.lat, s2.lng); + } + } + }); + } + + // 2. Latest 5 + const latest = trips.slice(0, 5).map(t => { + const segs = t.segments || [{ lineKey: t.lineKey, fromId: t.fromId, toId: t.toId }]; + const lineNames = segs.map(s => s.lineKey.split(':').pop()).join(' → '); // Simplified Title + + let tripDist = 0; + const allCoords = []; + + // Collect Coords for SVG + segs.forEach(seg => { + const key = `${seg.lineKey}_${seg.fromId}_${seg.toId}`; + const geom = segmentGeometries ? segmentGeometries.get(key) : null; + if (geom && geom.coords) { + if (geom.isMulti) { + geom.coords.forEach(c => { + allCoords.push(c); + if(turf) tripDist += turf.length(turf.lineString(c.map(p => [p[1], p[0]]))); + }); + } else { + allCoords.push(geom.coords); + if(turf) tripDist += turf.length(turf.lineString(geom.coords.map(p => [p[1], p[0]]))); + } + } + }); + + // Generate SVG Points (PCA Logic simplified reuse) + let svgPoints = ""; + if (allCoords.length > 0) { + // Flatten all points + let flatPoints = []; + allCoords.forEach(c => flatPoints.push(...c)); + + if (flatPoints.length > 1) { + // Centroid + let sumLat = 0, sumLng = 0; + flatPoints.forEach(p => { sumLat += p[0]; sumLng += p[1]; }); + const cenLat = sumLat / flatPoints.length; + const cenLng = sumLng / flatPoints.length; + + // Covariance + let u20 = 0, u02 = 0, u11 = 0; + flatPoints.forEach(pt => { + const x = pt[1] - cenLng; + const y = pt[0] - cenLat; + u20 += x * x; u02 += y * y; u11 += x * y; + }); + const theta = 0.5 * Math.atan2(2 * u11, u20 - u02); + const cosT = Math.cos(-theta); + const sinT = Math.sin(-theta); + + // Rotate & BBox + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + const rotated = flatPoints.map(pt => { + const x = pt[1] - cenLng; + const y = pt[0] - cenLat; + const rx = x * cosT - y * sinT; + const ry = x * sinT + y * cosT; + if (rx < minX) minX = rx; if (rx > maxX) maxX = rx; + if (ry < minY) minY = ry; if (ry > maxY) maxY = ry; + return [ry, rx]; + }); + + const w = maxX - minX || 0.001; + const h = maxY - minY || 0.001; + + // Pad + const padX = w * 0.1; const padY = h * 0.1; + const vMinX = minX - padX; const vMinY = minY - padY; + const vW = w + padX * 2; const vH = h + padY * 2; + + // Project to 0-100, 0-50 space + svgPoints = rotated.map(pt => { + const px = ((pt[1] - vMinX) / vW) * 100; + const py = 50 - ((pt[0] - vMinY) / vH) * 50; + return `${px.toFixed(1)},${py.toFixed(1)}`; // Optimize precision + }).join(' '); + } + } + + return { + id: t.id, + date: t.date, + title: lineNames, + dist: tripDist, + svg_points: svgPoints + }; + }); + + return { + count: totalTrips, + lines: uniqueLines, + dist: totalDist, + latest: latest + }; +}; + +const GithubCardModal = ({ isOpen, onClose, username }) => { + if (!isOpen || !username) return null; + const url = `${window.location.origin}/api/card?user=${username}`; + const md = `[![RailRound Stats](${url})](${window.location.origin})`; + + return ( +
+
e.stopPropagation()}> +
+

GitHub Profile Decoration

+ +
+
+
+ Preview +
+
+ +
+