diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts index b8ca121..b94d7b5 100644 --- a/frontend/app/api/notes/route.ts +++ b/frontend/app/api/notes/route.ts @@ -143,7 +143,7 @@ export async function GET(req: Request) { // Use service-role so download status is authoritative for this user only (no RLS ambiguity). const currentUserId = user.id; - let downloadedIds = new Set(); + const downloadedIds = new Set(); if (ids.length > 0) { const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); const { data: downloadsData } = await adminClient diff --git a/frontend/app/dashboard/dashboard.css b/frontend/app/dashboard/dashboard.css index afbea4f..1e61de3 100644 --- a/frontend/app/dashboard/dashboard.css +++ b/frontend/app/dashboard/dashboard.css @@ -15,6 +15,12 @@ gap: 24px; } +.dashboard-header-right { + display: flex; + align-items: center; + gap: 16px; +} + .dashboard-credit-summary { display: flex; align-items: center; @@ -34,6 +40,32 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } +.dashboard-profile-icon { + width: 40px; + height: 40px; + border-radius: 999px; + border: 1px solid #d0cbc2; + background: #ffffff; + color: #5f594f; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 22px -16px rgba(0, 0, 0, 0.35); +} + +.dashboard-profile-icon { + width: 40px; + height: 40px; + border-radius: 999px; + border: 1px solid #d0cbc2; + background: #ffffff; + color: #5f594f; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 22px -16px rgba(0, 0, 0, 0.35); +} + .dashboard-kicker { font-size: 0.9rem; color: #4ade80; @@ -775,4 +807,4 @@ align-self: stretch; text-align: center; } -} +} \ No newline at end of file diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index cfd9700..ef01dcc 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { supabase } from "@/lib/supabaseClient"; import PDFThumbnail from "@/app/components/pdf/PDFThumbnail"; import "./dashboard.css"; +import ProfileIcons from "./profile-icon"; type ClassOption = { id: string; @@ -690,13 +691,16 @@ export default function DashboardPage() {
Notes hub

Dashboard

-
- - Credits: {credits ?? "—"} - - - Free Downloads: {freeDownloads ?? "—"} - +
+
+ + Credits: {credits ?? "—"} + + + Free Downloads: {freeDownloads ?? "—"} + +
+
@@ -1336,4 +1340,4 @@ export default function DashboardPage() { )} ); -} +} \ No newline at end of file diff --git a/frontend/app/dashboard/profile-dashboard/page.tsx b/frontend/app/dashboard/profile-dashboard/page.tsx new file mode 100644 index 0000000..292e565 --- /dev/null +++ b/frontend/app/dashboard/profile-dashboard/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/lib/supabaseClient"; +import "./profile-dashboard.css"; + +export default function Page() { + const [accessToken, setAccessToken] = useState(null); + const [tokenLoaded, setTokenLoaded] = useState(false); + const [credits, setCredits] = useState(null); + const [creditsError, setCreditsError] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); + const router = useRouter(); + + useEffect(() => { + const loadSession = async () => { + const { data, error } = await supabase.auth.getSession(); + if (error) { + setCreditsError("Not authenticated"); + } + Promise.resolve().then(() => { + setAccessToken(data.session?.access_token ?? null); + setTokenLoaded(true); + }); + }; + loadSession(); + }, []); + + const refreshCredits = useCallback(async () => { + if (!accessToken) { + setCredits(null); + return; + } + + try { + const res = await fetch("/api/credits", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + setCreditsError("Failed to load credits"); + setCredits(null); + return; + } + + const data = await res.json(); + setCredits(Number.isFinite(data?.credits) ? Number(data.credits) : 0); + setCreditsError(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + setCreditsError("Failed to load credits"); + setCredits(null); + } + }, [accessToken]); + + useEffect(() => { + if (!tokenLoaded) return; + const timeoutId = window.setTimeout(() => { + void refreshCredits(); + }, 0); + return () => window.clearTimeout(timeoutId); + }, [refreshCredits, tokenLoaded]); + + const handleLogout = useCallback(async () => { + setLoggingOut(true); + await supabase.auth.signOut(); + router.replace("/"); + }, [router]); + + return ( +
+
+
+

Cal Poly SLO

+

Profile Dashboard

+
+ + Back to Dashboard + +
+
+
+ Credits + {credits ?? "—"} +

+

+ {creditsError ? ( +

{creditsError}

+ ) : null} +
+
+
+ +
+
+ ); +} diff --git a/frontend/app/dashboard/profile-dashboard/profile-dashboard.css b/frontend/app/dashboard/profile-dashboard/profile-dashboard.css new file mode 100644 index 0000000..e370227 --- /dev/null +++ b/frontend/app/dashboard/profile-dashboard/profile-dashboard.css @@ -0,0 +1,139 @@ +.profile-dashboard { + min-height: 100vh; + padding: 48px 24px 72px; + background: radial-gradient(circle at top right, #f7f0d4 0%, #f2f7e8 45%, #e6f4e6 100%); + color: #1f2d1f; + font-family: "Georgia", "Times New Roman", serif; +} + +.profile-dashboard__header { + display: flex; + flex-direction: column; + gap: 18px; + align-items: flex-start; + justify-content: space-between; + margin: 0 auto 32px; + max-width: 960px; +} + +.profile-dashboard__eyebrow { + font-size: 2rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #3b5f45; + margin: 0 0 8px; +} + +.profile-dashboard__subtitle { + max-width: 520px; + margin: 10px 0 0; + color: #3b4a34; + font-size: 1.05rem; +} + +.profile-dashboard__back { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + border-radius: 999px; + border: 1px solid #99b69a; + background: #f7f0d4; + color: #2b4a31; + text-decoration: none; + font-weight: 600; + box-shadow: 0 6px 16px rgba(67, 103, 68, 0.12); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.profile-dashboard__back:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(67, 103, 68, 0.18); +} + +.profile-dashboard__credits { + margin: 0 auto 36px; + max-width: 960px; +} + +.profile-dashboard__credit-card { + border-radius: 20px; + padding: 28px; + background: linear-gradient(135deg, #e6f4e6 0%, #f7f0d4 100%); + border: 1px solid #c2d7b9; + box-shadow: 0 18px 32px rgba(43, 74, 49, 0.12); +} + +.profile-dashboard__label { + display: block; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #3b5f45; + margin-bottom: 10px; +} + +.profile-dashboard__value { + display: block; + font-size: 3.1rem; + font-weight: 700; + color: #2f4f34; + margin-bottom: 8px; +} + +.profile-dashboard__hint { + margin: 0; + color: #3f5a3f; + font-size: 1rem; +} + +.profile-dashboard__error { + margin: 12px 0 0; + color: #7a2f2f; + font-weight: 600; +} + +.profile-dashboard__actions { + margin: 0 auto; + max-width: 960px; + padding-top: 96px; + display: flex; + justify-content: center; +} + +.profile-dashboard__logout { + border: none; + background: #2f5b3f; + color: #f7f0d4; + padding: 12px 22px; + font-size: 1rem; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + box-shadow: 0 10px 18px rgba(47, 91, 63, 0.2); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + width: 33%; + min-width: 160px; + max-width: 240px; +} + +.profile-dashboard__logout:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.profile-dashboard__logout:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 24px rgba(47, 91, 63, 0.24); +} + +@media (min-width: 768px) { + .profile-dashboard__header { + flex-direction: row; + align-items: center; + } + + .profile-dashboard__credit-card { + padding: 36px; + } +} \ No newline at end of file diff --git a/frontend/app/dashboard/profile-icon.tsx b/frontend/app/dashboard/profile-icon.tsx new file mode 100644 index 0000000..9304273 --- /dev/null +++ b/frontend/app/dashboard/profile-icon.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +export default function ProfileIcons() { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/app/dashboard/profile/profile-icon.tsx b/frontend/app/dashboard/profile/profile-icon.tsx new file mode 100644 index 0000000..6629994 --- /dev/null +++ b/frontend/app/dashboard/profile/profile-icon.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +export default function ProfileIcons() { + return ( + + + + ); +} diff --git a/frontend/app/profile-dashboard/page.tsx b/frontend/app/profile-dashboard/page.tsx new file mode 100644 index 0000000..292e565 --- /dev/null +++ b/frontend/app/profile-dashboard/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/lib/supabaseClient"; +import "./profile-dashboard.css"; + +export default function Page() { + const [accessToken, setAccessToken] = useState(null); + const [tokenLoaded, setTokenLoaded] = useState(false); + const [credits, setCredits] = useState(null); + const [creditsError, setCreditsError] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); + const router = useRouter(); + + useEffect(() => { + const loadSession = async () => { + const { data, error } = await supabase.auth.getSession(); + if (error) { + setCreditsError("Not authenticated"); + } + Promise.resolve().then(() => { + setAccessToken(data.session?.access_token ?? null); + setTokenLoaded(true); + }); + }; + loadSession(); + }, []); + + const refreshCredits = useCallback(async () => { + if (!accessToken) { + setCredits(null); + return; + } + + try { + const res = await fetch("/api/credits", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + setCreditsError("Failed to load credits"); + setCredits(null); + return; + } + + const data = await res.json(); + setCredits(Number.isFinite(data?.credits) ? Number(data.credits) : 0); + setCreditsError(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + setCreditsError("Failed to load credits"); + setCredits(null); + } + }, [accessToken]); + + useEffect(() => { + if (!tokenLoaded) return; + const timeoutId = window.setTimeout(() => { + void refreshCredits(); + }, 0); + return () => window.clearTimeout(timeoutId); + }, [refreshCredits, tokenLoaded]); + + const handleLogout = useCallback(async () => { + setLoggingOut(true); + await supabase.auth.signOut(); + router.replace("/"); + }, [router]); + + return ( +
+
+
+

Cal Poly SLO

+

Profile Dashboard

+
+ + Back to Dashboard + +
+
+
+ Credits + {credits ?? "—"} +

+

+ {creditsError ? ( +

{creditsError}

+ ) : null} +
+
+
+ +
+
+ ); +} diff --git a/frontend/app/profile-dashboard/profile-dashboard.css b/frontend/app/profile-dashboard/profile-dashboard.css new file mode 100644 index 0000000..d55e5f5 --- /dev/null +++ b/frontend/app/profile-dashboard/profile-dashboard.css @@ -0,0 +1,139 @@ +.profile-dashboard { + min-height: 100vh; + padding: 48px 24px 72px; + background: radial-gradient(circle at top right, #f7f0d4 0%, #f2f7e8 45%, #e6f4e6 100%); + color: #1f2d1f; + font-family: "Georgia", "Times New Roman", serif; +} + +.profile-dashboard__header { + display: flex; + flex-direction: column; + gap: 18px; + align-items: flex-start; + justify-content: space-between; + margin: 0 auto 32px; + max-width: 960px; +} + +.profile-dashboard__eyebrow { + font-size: 2rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #3b5f45; + margin: 0 0 8px; +} + +.profile-dashboard__subtitle { + max-width: 520px; + margin: 10px 0 0; + color: #3b4a34; + font-size: 1.05rem; +} + +.profile-dashboard__back { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + border-radius: 999px; + border: 1px solid #99b69a; + background: #f7f0d4; + color: #2b4a31; + text-decoration: none; + font-weight: 600; + box-shadow: 0 6px 16px rgba(67, 103, 68, 0.12); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.profile-dashboard__back:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(67, 103, 68, 0.18); +} + +.profile-dashboard__credits { + margin: 0 auto 36px; + max-width: 960px; +} + +.profile-dashboard__credit-card { + border-radius: 20px; + padding: 28px; + background: linear-gradient(135deg, #e6f4e6 0%, #f7f0d4 100%); + border: 1px solid #c2d7b9; + box-shadow: 0 18px 32px rgba(43, 74, 49, 0.12); +} + +.profile-dashboard__label { + display: block; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #3b5f45; + margin-bottom: 10px; +} + +.profile-dashboard__value { + display: block; + font-size: 3.1rem; + font-weight: 700; + color: #2f4f34; + margin-bottom: 8px; +} + +.profile-dashboard__hint { + margin: 0; + color: #3f5a3f; + font-size: 1rem; +} + +.profile-dashboard__error { + margin: 12px 0 0; + color: #7a2f2f; + font-weight: 600; +} + +.profile-dashboard__actions { + margin: 0 auto; + max-width: 960px; + padding-top: 96px; + display: flex; + justify-content: center; +} + +.profile-dashboard__logout { + border: none; + background: #2f5b3f; + color: #f7f0d4; + padding: 12px 22px; + font-size: 1rem; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + box-shadow: 0 10px 18px rgba(47, 91, 63, 0.2); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + width: 33%; + min-width: 160px; + max-width: 240px; +} + +.profile-dashboard__logout:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.profile-dashboard__logout:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 24px rgba(47, 91, 63, 0.24); +} + +@media (min-width: 768px) { + .profile-dashboard__header { + flex-direction: row; + align-items: center; + } + + .profile-dashboard__credit-card { + padding: 36px; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0ba7fd..412f48a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2517,7 +2516,6 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2611,7 +2609,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3149,7 +3146,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3715,7 +3711,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4637,7 +4632,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4823,7 +4817,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8724,7 +8717,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8734,7 +8726,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9899,7 +9890,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10112,7 +10102,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10555,7 +10544,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }