diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 6e86d218..cee45477 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,5 +1,6 @@ "use client"; +import ThemePresetPicker from "@/components/ThemePresetPicker"; import { Suspense, useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; @@ -21,11 +22,7 @@ interface UserSettings { bio: string; is_public: boolean; leaderboard_opt_in: boolean; - weekly_digest_opt_in: boolean; has_wakatime_key?: boolean; - discord_webhook_url?: string; - timezone?: string; - pinned_repos?: string[]; } interface LinkedAccount { @@ -139,19 +136,10 @@ function SettingsPageContent() { const [showBioPreview, setShowBioPreview] = useState(false); const [savingBio, setSavingBio] = useState(false); const [savingWakatime, setSavingWakatime] = useState(false); - const [discordWebhook, setDiscordWebhook] = useState(""); - const [timezone, setTimezone] = useState(""); - const [savingDiscord, setSavingDiscord] = useState(false); - const [testingDiscord, setTestingDiscord] = useState(false); const [isDirty, setIsDirty] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); const [pendingPath, setPendingPath] = useState(null); - // Spotlight Repos States - const [userRepos, setUserRepos] = useState([]); - const [loadingRepos, setLoadingRepos] = useState(false); - const [repoSearchQuery, setRepoSearchQuery] = useState(""); - const statusMessage = useMemo( () => getStatusMessage(searchParams.get("success"), searchParams.get("error")), @@ -243,6 +231,10 @@ function SettingsPageContent() { try { const res = await fetch("/api/user/settings"); if (res.ok) { + const data = await res.json(); + setSettings(data); + setBioDraft(data.bio ?? ""); +} const data = await res.json(); setSettings(data); setBioDraft(data.bio ?? ""); @@ -260,78 +252,6 @@ function SettingsPageContent() { loadSettings(); }, [session, status]); - // Load active repos for spotlight pinning - useEffect(() => { - if (status !== "authenticated") return; - setLoadingRepos(true); - fetch("/api/metrics/repos?days=90") - .then((r) => r.json()) - .then((d) => { - const names = (d.repos ?? []).map((r: any) => r.name); - setUserRepos(names); - }) - .catch((err) => console.error("Failed to load user repos:", err)) - .finally(() => setLoadingRepos(false)); - }, [status]); - - const handleUpdatePinnedRepos = async (newPins: string[]) => { - if (!settings) return; - setSaving(true); - try { - const res = await fetch("/api/user/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pinned_repos: newPins }), - }); - if (res.ok) { - const updated = await res.json(); - setSettings(updated); - toast.success("Spotlight repositories updated successfully!"); - } else { - toast.error("Failed to update spotlight repositories."); - } - } catch (err) { - console.error(err); - toast.error("Error updating spotlight repositories."); - } finally { - setSaving(false); - } - }; - - const handlePinRepo = async (repoName: string) => { - if (!settings) return; - const currentPins = settings.pinned_repos || []; - if (currentPins.includes(repoName)) return; - if (currentPins.length >= 3) { - toast.error("Maximum 3 pinned repositories allowed!"); - return; - } - - const updatedPins = [...currentPins, repoName]; - await handleUpdatePinnedRepos(updatedPins); - }; - - const handleUnpinRepo = async (repoName: string) => { - if (!settings) return; - const currentPins = settings.pinned_repos || []; - const updatedPins = currentPins.filter((name) => name !== repoName); - await handleUpdatePinnedRepos(updatedPins); - }; - - const handleMovePin = async (index: number, direction: "up" | "down") => { - if (!settings) return; - const currentPins = [...(settings.pinned_repos || [])]; - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= currentPins.length) return; - - // Swap elements - const temp = currentPins[index]; - currentPins[index] = currentPins[targetIndex]; - currentPins[targetIndex] = temp; - - await handleUpdatePinnedRepos(currentPins); - }; - useEffect(() => { if (status !== "authenticated" || !session?.githubLogin) { return; @@ -409,30 +329,6 @@ function SettingsPageContent() { } }; - const handleToggleWeeklyDigest = async (value: boolean) => { - if (!settings) return; - - setSaving(true); - try { - const res = await fetch("/api/user/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ weekly_digest_opt_in: value }), - }); - - if (res.ok) { - const updated = await res.json(); - setSettings(updated); - } else { - console.error("Failed to update weekly digest setting"); - } - } catch (error) { - console.error("Error updating weekly digest setting:", error); - } finally { - setSaving(false); - } - }; - const handleSaveWakatime = async () => { if (!settings) return; setSavingWakatime(true); @@ -460,6 +356,7 @@ function SettingsPageContent() { } }; + const handleSaveBio = async () => { if (!settings || bioDraft.length > 500) return; @@ -488,59 +385,8 @@ function SettingsPageContent() { setSavingBio(false); } }; - - const handleSaveDiscord = async () => { - if (!settings) return; - setSavingDiscord(true); - try { - const res = await fetch("/api/user/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ discord_webhook_url: discordWebhook, timezone }), - }); - if (res.ok) { - const updated = await res.json(); - setSettings(updated); - setIsDirty(false); - toast.success(discordWebhook === "" ? "Discord Webhook removed" : "Discord settings saved successfully!"); - } else { - const errorData = await res.json(); - toast.error(errorData.error || "Failed to update Discord settings"); - } - } catch (error) { - console.error("Error updating Discord settings:", error); - toast.error("Failed to update Discord settings"); - } finally { - setSavingDiscord(false); - } - }; - - const handleTestDiscord = async () => { - if (!discordWebhook) { - toast.error("Please enter a Webhook URL first"); - return; - } - setTestingDiscord(true); - try { - const res = await fetch("/api/user/settings/discord-test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ webhookUrl: discordWebhook }), - }); - if (res.ok) { - toast.success("Test notification sent! Check your Discord server."); - } else { - const errorData = await res.json(); - toast.error(errorData.error || "Failed to send test notification"); - } - } catch (error) { - console.error("Error sending test notification:", error); - toast.error("Failed to send test notification"); - } finally { - setTestingDiscord(false); - } - }; - + + const copyShareLink = () => { if (!settings) return; const link = `${window.location.origin}/u/${settings.github_login}`; @@ -639,11 +485,10 @@ function SettingsPageContent() { {statusMessage && (
{statusMessage.message}
@@ -902,6 +747,20 @@ function SettingsPageContent() { )} +
+
+

+ Dashboard Theme +

+ +

+ Personalize your dashboard appearance with curated developer themes. +

+
+ + +
+
diff --git a/src/app/globals.css b/src/app/globals.css index ed0d661f..1119c620 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,7 +22,7 @@ --control: #f1f5f9; --control-hover: #e2e8f0; --tooltip: #ffffff; - --tooltip-foreground: #111827; + --tooltip-foreground: #0f172a; --destructive-muted: rgba(239, 68, 68, 0.1); --destructive-muted-border: rgba(239, 68, 68, 0.3); --destructive-foreground: #ffffff; @@ -52,7 +52,6 @@ --accent: #60a5fa; --accent-secondary: #3b82f6; - --focus-ring: #a5b4fc; --success: #10b981; --warning: #fbbf24; @@ -81,8 +80,113 @@ html { scroll-behavior: smooth; } -html, body { - overscroll-behavior: none; + +.theme-dracula { + --background: #282a36; + --foreground: #f8f8f2; + --muted-foreground: #bd93f9; + --card: #343746; + --card-foreground: #f8f8f2; + --card-muted: #3b3f51; + --border: #44475a; + --accent: #bd93f9; + --accent-secondary: #ff79c6; + --focus-ring: #ff79c6; + --success: #50fa7b; + --warning: #f1fa8c; + --destructive: #ff5555; + --accent-soft: rgba(189, 147, 249, 0.18); + --accent-foreground: #ffffff; + --control: #3b3f51; + --control-hover: #4b5268; + --tooltip: #343746; + --tooltip-foreground: #f8f8f2; + --destructive-muted: rgba(255, 85, 85, 0.12); + --destructive-muted-border: rgba(255, 85, 85, 0.3); + --destructive-foreground: #ffffff; + --shadow-soft: 0 16px 34px -24px rgba(0, 0, 0, 0.7); + --shadow-medium: 0 24px 45px -28px rgba(0, 0, 0, 0.8); +} + +.theme-nord { + --background: #2e3440; + --foreground: #eceff4; + --muted-foreground: #d8dee9; + --card: #3b4252; + --card-foreground: #eceff4; + --card-muted: #434c5e; + --border: #4c566a; + --accent: #88c0d0; + --accent-secondary: #5e81ac; + --focus-ring: #88c0d0; + --success: #a3be8c; + --warning: #ebcb8b; + --destructive: #bf616a; + --accent-soft: rgba(136, 192, 208, 0.18); + --accent-foreground: #ffffff; + --control: #434c5e; + --control-hover: #4c566a; + --tooltip: #3b4252; + --tooltip-foreground: #eceff4; + --destructive-muted: rgba(191, 97, 106, 0.12); + --destructive-muted-border: rgba(191, 97, 106, 0.3); + --destructive-foreground: #ffffff; + --shadow-soft: 0 16px 34px -24px rgba(15, 23, 42, 0.8); + --shadow-medium: 0 24px 45px -28px rgba(15, 23, 42, 0.9); +} + +.theme-catppuccin-mocha { + --background: #1e1e2e; + --foreground: #cdd6f4; + --muted-foreground: #bac2de; + --card: #313244; + --card-foreground: #cdd6f4; + --card-muted: #45475a; + --border: #585b70; + --accent: #cba6f7; + --accent-secondary: #f5c2e7; + --focus-ring: #f5c2e7; + --success: #a6e3a1; + --warning: #f9e2af; + --destructive: #f38ba8; + --accent-soft: rgba(203, 166, 247, 0.18); + --accent-foreground: #ffffff; + --control: #45475a; + --control-hover: #585b70; + --tooltip: #313244; + --tooltip-foreground: #cdd6f4; + --destructive-muted: rgba(243, 139, 168, 0.12); + --destructive-muted-border: rgba(243, 139, 168, 0.3); + --destructive-foreground: #ffffff; + --shadow-soft: 0 16px 34px -24px rgba(0, 0, 0, 0.75); + --shadow-medium: 0 24px 45px -28px rgba(0, 0, 0, 0.85); +} + +.theme-solarized-dark { + --background: #002b36; + --foreground: #eee8d5; + --muted-foreground: #93a1a1; + --card: #073642; + --card-foreground: #eee8d5; + --card-muted: #0b3b49; + --border: #586e75; + --accent: #2aa198; + --accent-secondary: #268bd2; + --focus-ring: #2aa198; + --success: #859900; + --warning: #b58900; + --destructive: #dc322f; + --accent-soft: rgba(42, 161, 152, 0.18); + --accent-foreground: #ffffff; + --control: #0b3b49; + --control-hover: #14505f; + --tooltip: #073642; + --tooltip-foreground: #eee8d5; + --destructive-muted: rgba(220, 50, 47, 0.12); + --destructive-muted-border: rgba(220, 50, 47, 0.3); + --destructive-foreground: #ffffff; + --shadow-soft: 0 16px 34px -24px rgba(0, 0, 0, 0.75); + --shadow-medium: 0 24px 45px -28px rgba(0, 0, 0, 0.85); } /* Strip body background on landing page so the dark bg is visible */ @@ -104,7 +208,6 @@ body { var(--background); color: var(--foreground); transition: background-color 200ms ease, color 200ms ease; - overscroll-behavior: none; } .surface-card { @@ -215,7 +318,7 @@ body { .dark::-webkit-scrollbar { width: 6px; height: 6px; } .dark::-webkit-scrollbar-track { background: transparent; } .dark::-webkit-scrollbar-thumb { background: #222; border-radius: 6px; } -.dark::-webkit-scrollbar-thumb:hover { background: #9ca3af; } +.dark::-webkit-scrollbar-thumb:hover { background: #333; } /* ───────────────────────────────────────── LANDING PAGE @@ -342,10 +445,19 @@ body { position: relative; } +.lnd-footer-link { + font-family: var(--font-jetbrains, ui-monospace, monospace); + font-size: 11px; + color: #333; + text-decoration: none; + transition: color 0.2s; +} +.lnd-footer-link:hover { color: #818cf8; } @media (max-width: 640px) { .lnd-ticker { animation-duration: 20s !important; } } +} /* --- Accessibility Fix: Respect prefers-reduced-motion (WCAG 2.3.3) --- */ @media (prefers-reduced-motion: reduce) { diff --git a/src/components/ThemeContext.tsx b/src/components/ThemeContext.tsx index e4799c2d..cb6df9cd 100644 --- a/src/components/ThemeContext.tsx +++ b/src/components/ThemeContext.tsx @@ -2,10 +2,11 @@ import React, { createContext, useContext, useEffect, useLayoutEffect, useState } from "react"; -type Theme = "light" | "dark"; +import { Theme, themes } from "@/lib/themes"; interface ThemeContextType { theme: Theme | undefined; + setTheme: (theme: Theme) => void; toggleTheme: () => void; } @@ -22,7 +23,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ useSafeLayoutEffect(() => { const storedTheme = localStorage.getItem(STORAGE_KEY) as Theme | null; - if (storedTheme === "dark" || storedTheme === "light") { + if (storedTheme && storedTheme in themes) { setTheme(storedTheme); return; } @@ -32,21 +33,43 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ useSafeLayoutEffect(() => { if (!theme) return; - document.documentElement.classList.toggle("dark", theme === "dark"); - document.documentElement.style.colorScheme = theme; - }, [theme]); + const root = document.documentElement; + + const themeClasses = [ + "theme-dracula", + "theme-nord", + "theme-catppuccin-mocha", + "theme-solarized-dark", + ]; + + root.classList.remove(...themeClasses); + + const currentTheme = themes[theme]; + const isDarkTheme = currentTheme.mode === "dark"; + + root.classList.toggle("dark", isDarkTheme); + + if (theme !== "light" && theme !== "dark") { + root.classList.add(`theme-${theme}`); + } + + root.style.colorScheme = isDarkTheme ? "dark" : "light"; - useEffect(() => { - if (!theme) return; localStorage.setItem(STORAGE_KEY, theme); }, [theme]); const toggleTheme = () => { - setTheme((prev) => (prev === "dark" ? "light" : "dark")); + setTheme((prev) => { + if (prev === "light") { + return "dark"; + } + + return "light"; + }); }; return ( - + {children} ); @@ -58,4 +81,4 @@ export const useTheme = () => { throw new Error("useTheme must be used within a ThemeProvider"); } return context; -}; +}; \ No newline at end of file diff --git a/src/components/ThemePresetPicker.tsx b/src/components/ThemePresetPicker.tsx new file mode 100644 index 00000000..8e178459 --- /dev/null +++ b/src/components/ThemePresetPicker.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Theme, themes } from "@/lib/themes"; +import { useTheme } from "./ThemeContext"; + +export default function ThemePresetPicker() { + const { theme, setTheme } = useTheme(); + + return ( +
+ {Object.entries(themes).map(([themeKey, config]) => { + const isActive = theme === themeKey; + + return ( + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 41dc50c6..e3cc892b 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -48,11 +48,13 @@ export default function ThemeToggle() { const isDark = theme === "dark"; + + return (