mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
feat: improve themes (#150)
- removed code themes in favour of themes having built-in code syntax themes. # Themes (30) ## Supabase <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/ebe48a46-93ee-4bea-9cc8-dda97531a2c8" /> ## Vercel <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/9736aa0d-7b72-47d7-bf24-8416aeb14b08" /> ## Evil Rabbit <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/7e63a20d-634d-4143-8179-2b41129412b9" /> ## Mintlify <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/67d61fb8-a9d0-44da-9d65-2dd2e5b35214" /> ## Tailwind <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/88bc8177-f5a3-45e9-ba39-6ff3fdceb362" /> ## OpenAI <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/432386b2-a7fb-40d0-85a7-7ed0930a5f3f" /> ## Prisma <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/73a7b0a1-8e09-4321-b21e-c2baaf866cfa" /> ## ElevenLabs <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/57de3e05-5924-40f2-993c-c0ba651bb6ed" /> ## Resend <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/a9855ba8-135d-4b5c-a77d-0c081dcf552b" /> ## Trigger.dev <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/f833d2f6-0f02-4f85-b8ba-da20ce489c98" /> ## Nuxt <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/80cf39d5-b04c-4914-b0fe-e057581827a4" /> ## BrowserBase <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/6be2b610-0e67-4149-8e7c-6ad3e670a96d" /> ## Cloudflare <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/3dd6847d-e848-4718-af26-4fae755943b1" /> ## Gemini <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/a1ba05fd-4299-4f6b-8211-18182ec96a5e" /> ## Stripe <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/570cf5c9-d00b-4306-b7fe-72cbd6eeb941" /> ## Bitmap <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/42e975b4-f1db-443f-9bac-daff02fdea7f" /> ## Nior <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/0f270ede-0325-4061-9662-e4db4e06e44b" /> ## Ice <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/96de2a91-61b9-46c6-a00f-063b6f262559" /> ## Sand <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/162404fd-b1c6-4991-bf8f-c875fead82b0" /> ## Forest <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/df5e64fc-d31f-4dce-b249-4bedb17e10aa" /> ## Mono <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/f9f3e93a-c70c-457a-b214-4fecdfd31992" /> ## Breeze <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/227fb948-ab69-44f5-bb03-16df96a73bd3" /> ## Candy <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/e952135d-0e7b-4b7e-9e27-8259e78bd71c" /> ## Crismon <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/8fd0ffd4-48a4-4739-8d7b-befa2379eab3" /> ## Falcon <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/19a03c00-f31b-4977-ad7b-4a6ef70e115e" /> ## Meadow <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/31bae90b-dc5b-4e2e-85d7-6404e1cb213c" /> ## Midnight <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/aa2aecce-1865-4eee-a653-f43490bcc895" /> ## Raindrop <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/793e012c-c6bb-4467-9408-48670754d4f7" /> ## Sunset <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/34a05617-7502-447e-aba3-9fcda7564c0e" /> ## Firecrawl <img width="2820" height="2124" alt="image" src="https://github.com/user-attachments/assets/25ef8dc0-352f-4f81-b00f-df1ca4d3d59d" />
This commit is contained in:
@@ -169,7 +169,8 @@ model UserSettings {
|
||||
userId String @id
|
||||
displayName String?
|
||||
theme String @default("system")
|
||||
colorTheme String @default("midnight")
|
||||
colorTheme String @default("hub")
|
||||
colorMode String @default("dark")
|
||||
ghostModel String @default("auto")
|
||||
useOwnApiKey Boolean @default(false)
|
||||
openrouterApiKey String?
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getNotifications, checkIsStarred } from "@/lib/github";
|
||||
import { type GhostTabState } from "@/lib/chat-store";
|
||||
import type { NotificationItem } from "@/lib/github-types";
|
||||
import { ColorThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { CodeThemeProvider } from "@/components/theme/code-theme-provider";
|
||||
import { GitHubLinkInterceptor } from "@/components/shared/github-link-interceptor";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { MutationEventProvider } from "@/components/shared/mutation-event-provider";
|
||||
@@ -51,91 +50,77 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
<GlobalChatProvider initialTabState={initialTabState}>
|
||||
<MutationEventProvider>
|
||||
<ColorThemeProvider>
|
||||
<CodeThemeProvider>
|
||||
<GitHubLinkInterceptor>
|
||||
<TooltipProvider>
|
||||
<NavigationProgress />
|
||||
<div className="flex flex-col h-dvh overflow-y-auto lg:overflow-hidden">
|
||||
<AppNavbar
|
||||
session={
|
||||
session
|
||||
}
|
||||
notifications={
|
||||
notifications
|
||||
}
|
||||
/>
|
||||
<div className="mt-10 lg:h-[calc(100dvh-var(--spacing)*10)] flex flex-col px-2 sm:px-4 pt-2 lg:overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
<Suspense>
|
||||
<GlobalChatPanel />
|
||||
</Suspense>
|
||||
</div>
|
||||
<OnboardingOverlay
|
||||
userName={
|
||||
session
|
||||
?.githubUser
|
||||
?.name ||
|
||||
session
|
||||
?.githubUser
|
||||
?.login ||
|
||||
""
|
||||
}
|
||||
userAvatar={
|
||||
session
|
||||
?.githubUser
|
||||
?.avatar_url ||
|
||||
""
|
||||
}
|
||||
bio={
|
||||
session
|
||||
?.githubUser
|
||||
?.bio ||
|
||||
""
|
||||
}
|
||||
company={
|
||||
session
|
||||
?.githubUser
|
||||
?.company ||
|
||||
""
|
||||
}
|
||||
location={
|
||||
session
|
||||
?.githubUser
|
||||
?.location ||
|
||||
""
|
||||
}
|
||||
publicRepos={
|
||||
session
|
||||
?.githubUser
|
||||
?.public_repos ??
|
||||
0
|
||||
}
|
||||
followers={
|
||||
session
|
||||
?.githubUser
|
||||
?.followers ??
|
||||
0
|
||||
}
|
||||
createdAt={
|
||||
session
|
||||
?.githubUser
|
||||
?.created_at ||
|
||||
""
|
||||
}
|
||||
onboardingDone={
|
||||
onboardingDone
|
||||
}
|
||||
initialStarredAuth={
|
||||
initialStarredAuth
|
||||
}
|
||||
initialStarredHub={
|
||||
initialStarredHub
|
||||
<GitHubLinkInterceptor>
|
||||
<TooltipProvider>
|
||||
<NavigationProgress />
|
||||
<div className="flex flex-col h-dvh overflow-y-auto lg:overflow-hidden">
|
||||
<AppNavbar
|
||||
session={session}
|
||||
notifications={
|
||||
notifications
|
||||
}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</GitHubLinkInterceptor>
|
||||
</CodeThemeProvider>
|
||||
<div className="mt-10 lg:h-[calc(100dvh-var(--spacing)*10)] flex flex-col px-2 sm:px-4 pt-2 lg:overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
<Suspense>
|
||||
<GlobalChatPanel />
|
||||
</Suspense>
|
||||
</div>
|
||||
<OnboardingOverlay
|
||||
userName={
|
||||
session?.githubUser
|
||||
?.name ||
|
||||
session?.githubUser
|
||||
?.login ||
|
||||
""
|
||||
}
|
||||
userAvatar={
|
||||
session?.githubUser
|
||||
?.avatar_url ||
|
||||
""
|
||||
}
|
||||
bio={
|
||||
session?.githubUser
|
||||
?.bio || ""
|
||||
}
|
||||
company={
|
||||
session?.githubUser
|
||||
?.company ||
|
||||
""
|
||||
}
|
||||
location={
|
||||
session?.githubUser
|
||||
?.location ||
|
||||
""
|
||||
}
|
||||
publicRepos={
|
||||
session?.githubUser
|
||||
?.public_repos ??
|
||||
0
|
||||
}
|
||||
followers={
|
||||
session?.githubUser
|
||||
?.followers ??
|
||||
0
|
||||
}
|
||||
createdAt={
|
||||
session?.githubUser
|
||||
?.created_at ||
|
||||
""
|
||||
}
|
||||
onboardingDone={
|
||||
onboardingDone
|
||||
}
|
||||
initialStarredAuth={
|
||||
initialStarredAuth
|
||||
}
|
||||
initialStarredHub={
|
||||
initialStarredHub
|
||||
}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</GitHubLinkInterceptor>
|
||||
</ColorThemeProvider>
|
||||
</MutationEventProvider>
|
||||
</GlobalChatProvider>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import {
|
||||
getCustomThemes,
|
||||
saveCustomTheme,
|
||||
deleteCustomTheme,
|
||||
extractColorsFromVSCodeTheme,
|
||||
} from "@/lib/code-themes/store";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const themes = await getCustomThemes(session.user.id);
|
||||
return Response.json(
|
||||
themes.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
mode: t.mode,
|
||||
bgColor: t.bgColor,
|
||||
fgColor: t.fgColor,
|
||||
accentColor: t.accentColor,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
let body: { name?: string; themeJson?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name, themeJson } = body;
|
||||
if (!name || typeof name !== "string") {
|
||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
if (!themeJson || typeof themeJson !== "object") {
|
||||
return Response.json({ error: "Theme JSON is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate it looks like a VS Code theme
|
||||
const json = themeJson as Record<string, unknown>;
|
||||
if (!json.tokenColors && !json.colors) {
|
||||
return Response.json(
|
||||
{ error: "Invalid VS Code theme: must contain tokenColors or colors" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { bg, fg, accent, mode } = extractColorsFromVSCodeTheme(json);
|
||||
const theme = await saveCustomTheme(
|
||||
session.user.id,
|
||||
name,
|
||||
mode,
|
||||
JSON.stringify(themeJson),
|
||||
bg,
|
||||
fg,
|
||||
accent,
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
mode: theme.mode,
|
||||
bgColor: theme.bgColor,
|
||||
fgColor: theme.fgColor,
|
||||
accentColor: theme.accentColor,
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to save theme" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
let body: { id?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { id } = body;
|
||||
if (!id || typeof id !== "string") {
|
||||
return Response.json({ error: "Theme ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = await deleteCustomTheme(id, session.user.id);
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Theme not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const settingsUpdateSchema = z
|
||||
displayName: z.string().max(100).optional(),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
colorTheme: z.string().max(50).optional(),
|
||||
colorMode: z.enum(["light", "dark"]).optional(),
|
||||
ghostModel: z.string().max(100).optional(),
|
||||
useOwnApiKey: z.boolean().optional(),
|
||||
openrouterApiKey: z.string().max(500).nullable().optional(),
|
||||
|
||||
@@ -233,7 +233,7 @@ body {
|
||||
font-size: var(--code-font-size, 13px);
|
||||
line-height: 20px;
|
||||
--cv-gutter-w: 3ch;
|
||||
background: var(--code-theme-bg, var(--code-bg));
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.code-content pre {
|
||||
@@ -334,7 +334,7 @@ tr.diff-search-match-active > td {
|
||||
|
||||
/* Shiki dual-theme (defaultColor: false) */
|
||||
.code-content .shiki {
|
||||
background-color: var(--code-theme-bg, var(--code-bg)) !important;
|
||||
background-color: var(--code-bg) !important;
|
||||
}
|
||||
:root:where(.dark) .code-content .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
@@ -499,7 +499,7 @@ tr.diff-search-match-active > td {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
font-family: var(--code-font-override, var(--font-code)), ui-monospace, monospace;
|
||||
background-color: var(--code-theme-bg, var(--code-block-bg));
|
||||
background-color: var(--code-block-bg);
|
||||
}
|
||||
|
||||
/* Shiki dual-theme support (defaultColor: false) */
|
||||
@@ -508,7 +508,7 @@ tr.diff-search-match-active > td {
|
||||
font-family: var(--code-font-override, var(--font-code)), ui-monospace, monospace;
|
||||
}
|
||||
.ghmd .shiki {
|
||||
background-color: var(--code-theme-bg, var(--code-block-bg)) !important;
|
||||
background-color: var(--code-block-bg) !important;
|
||||
}
|
||||
:root:where(.dark) .ghmd .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
@@ -520,7 +520,7 @@ tr.diff-search-match-active > td {
|
||||
}
|
||||
/* Override shiki's inline bg in markdown context */
|
||||
.ghmd pre.shiki {
|
||||
background-color: var(--code-theme-bg, var(--code-block-bg)) !important;
|
||||
background-color: var(--code-block-bg) !important;
|
||||
}
|
||||
|
||||
/* Code block wrapper with copy button */
|
||||
@@ -542,7 +542,7 @@ tr.diff-search-match-active > td {
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--code-theme-bg, var(--code-block-bg));
|
||||
background: var(--code-block-bg);
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
|
||||
@@ -43,8 +43,6 @@ import { getLanguageColor } from "@/lib/github-utils";
|
||||
import { useGlobalChatOptional } from "@/components/shared/global-chat-provider";
|
||||
import { getRecentViews, type RecentViewItem } from "@/lib/recent-views";
|
||||
import { useColorTheme } from "@/components/theme/theme-provider";
|
||||
import { useCodeTheme } from "@/components/theme/code-theme-provider";
|
||||
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
|
||||
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
|
||||
import { useMutationSubscription } from "@/hooks/use-mutation-subscription";
|
||||
import type { MutationEvent } from "@/lib/mutation-events";
|
||||
@@ -104,15 +102,7 @@ function matchRepoFromPathname(pathname: string): [string, string] | null {
|
||||
return [segments[0], segments[1]];
|
||||
}
|
||||
|
||||
type Mode =
|
||||
| "commands"
|
||||
| "search"
|
||||
| "theme"
|
||||
| "code-theme"
|
||||
| "accounts"
|
||||
| "settings"
|
||||
| "model"
|
||||
| "files";
|
||||
type Mode = "commands" | "search" | "theme" | "accounts" | "settings" | "model" | "files";
|
||||
|
||||
export function CommandMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -127,17 +117,12 @@ export function CommandMenu() {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const globalChat = useGlobalChatOptional();
|
||||
const {
|
||||
colorTheme,
|
||||
setColorTheme,
|
||||
themeId: currentThemeId,
|
||||
mode: currentMode,
|
||||
setTheme: setColorTheme,
|
||||
toggleMode,
|
||||
themes: colorThemes,
|
||||
darkThemes: colorDarkThemes,
|
||||
lightThemes: colorLightThemes,
|
||||
darkThemeId,
|
||||
lightThemeId,
|
||||
mode: _colorMode,
|
||||
} = useColorTheme();
|
||||
const { codeThemeDark, codeThemeLight, setCodeThemeDark, setCodeThemeLight } =
|
||||
useCodeTheme();
|
||||
const { emit } = useMutationEvents();
|
||||
|
||||
// Recently viewed
|
||||
@@ -789,29 +774,13 @@ export function CommandMenu() {
|
||||
"wave",
|
||||
"contrast",
|
||||
"style",
|
||||
"code",
|
||||
"syntax",
|
||||
],
|
||||
action: () => switchMode("theme"),
|
||||
icon: Palette,
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
name: "Code Theme",
|
||||
description: "Change syntax highlighting",
|
||||
keywords: [
|
||||
"code",
|
||||
"syntax",
|
||||
"editor",
|
||||
"highlight",
|
||||
"dracula",
|
||||
"monokai",
|
||||
"catppuccin",
|
||||
"shiki",
|
||||
"font",
|
||||
],
|
||||
action: () => switchMode("code-theme"),
|
||||
icon: Code,
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
name: "Account Settings",
|
||||
description: "Profile, accounts & sign out",
|
||||
@@ -1126,32 +1095,16 @@ export function CommandMenu() {
|
||||
}, [search, topUserRepos, filteredUserRepos, dedupedGithubResults, router]);
|
||||
|
||||
// --- Theme mode items ---
|
||||
const filteredDarkThemes = useMemo(() => {
|
||||
if (mode !== "theme") return colorDarkThemes;
|
||||
if (!search.trim()) return colorDarkThemes;
|
||||
const filteredThemes = useMemo(() => {
|
||||
if (mode !== "theme") return colorThemes;
|
||||
if (!search.trim()) return colorThemes;
|
||||
const s = search.toLowerCase();
|
||||
return colorDarkThemes.filter(
|
||||
return colorThemes.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(s) ||
|
||||
t.description.toLowerCase().includes(s),
|
||||
);
|
||||
}, [mode, search, colorDarkThemes]);
|
||||
|
||||
const filteredLightThemes = useMemo(() => {
|
||||
if (mode !== "theme") return colorLightThemes;
|
||||
if (!search.trim()) return colorLightThemes;
|
||||
const s = search.toLowerCase();
|
||||
return colorLightThemes.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(s) ||
|
||||
t.description.toLowerCase().includes(s),
|
||||
);
|
||||
}, [mode, search, colorLightThemes]);
|
||||
|
||||
const filteredThemes = useMemo(
|
||||
() => [...filteredDarkThemes, ...filteredLightThemes],
|
||||
[filteredDarkThemes, filteredLightThemes],
|
||||
);
|
||||
}, [mode, search, colorThemes]);
|
||||
|
||||
const themeItems = useMemo(() => {
|
||||
return filteredThemes.map((t) => ({
|
||||
@@ -1161,27 +1114,6 @@ export function CommandMenu() {
|
||||
}));
|
||||
}, [filteredThemes, setColorTheme]);
|
||||
|
||||
// --- Code theme mode items ---
|
||||
const filteredCodeThemes = useMemo(() => {
|
||||
if (mode !== "code-theme") return BUILT_IN_THEMES;
|
||||
if (!search.trim()) return BUILT_IN_THEMES;
|
||||
const s = search.toLowerCase();
|
||||
return BUILT_IN_THEMES.filter(
|
||||
(t) => t.name.toLowerCase().includes(s) || t.id.toLowerCase().includes(s),
|
||||
);
|
||||
}, [mode, search]);
|
||||
|
||||
const codeThemeItems = useMemo(() => {
|
||||
return filteredCodeThemes.map((t) => ({
|
||||
id: `code-theme-${t.id}`,
|
||||
action: () => {
|
||||
if (t.mode === "dark") setCodeThemeDark(t.id);
|
||||
else setCodeThemeLight(t.id);
|
||||
},
|
||||
keepOpen: true,
|
||||
}));
|
||||
}, [filteredCodeThemes, setCodeThemeDark, setCodeThemeLight]);
|
||||
|
||||
// --- Accounts mode items ---
|
||||
const handleSwitchAccount = useCallback(
|
||||
async (accountId: string | null) => {
|
||||
@@ -1320,11 +1252,6 @@ export function CommandMenu() {
|
||||
const settingsItems = useMemo(() => {
|
||||
const items: { id: string; action: () => void; keepOpen: boolean }[] = [
|
||||
{ id: "settings-theme", action: () => switchMode("theme"), keepOpen: true },
|
||||
{
|
||||
id: "settings-code-theme",
|
||||
action: () => switchMode("code-theme"),
|
||||
keepOpen: true,
|
||||
},
|
||||
{ id: "settings-model", action: () => switchMode("model"), keepOpen: true },
|
||||
{
|
||||
id: "settings-accounts",
|
||||
@@ -1396,15 +1323,13 @@ export function CommandMenu() {
|
||||
? searchItems
|
||||
: mode === "theme"
|
||||
? themeItems
|
||||
: mode === "code-theme"
|
||||
? codeThemeItems
|
||||
: mode === "accounts"
|
||||
? accountItems
|
||||
: mode === "settings"
|
||||
? settingsItems
|
||||
: mode === "model"
|
||||
? modelItems
|
||||
: fileItems;
|
||||
: mode === "accounts"
|
||||
? accountItems
|
||||
: mode === "settings"
|
||||
? settingsItems
|
||||
: mode === "model"
|
||||
? modelItems
|
||||
: fileItems;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
@@ -1445,11 +1370,7 @@ export function CommandMenu() {
|
||||
if (e.key === "Backspace" && mode !== "commands" && !search) {
|
||||
e.preventDefault();
|
||||
// Sub-modes go back to settings, others go to commands
|
||||
switchMode(
|
||||
mode === "model" || mode === "code-theme"
|
||||
? "settings"
|
||||
: "commands",
|
||||
);
|
||||
switchMode(mode === "model" ? "settings" : "commands");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1530,8 +1451,6 @@ export function CommandMenu() {
|
||||
<FileText className="size-4 text-muted-foreground/50 shrink-0" />
|
||||
) : mode === "theme" ? (
|
||||
<Palette className="size-4 text-muted-foreground/50 shrink-0" />
|
||||
) : mode === "code-theme" ? (
|
||||
<Code className="size-4 text-muted-foreground/50 shrink-0" />
|
||||
) : mode === "accounts" ? (
|
||||
<Users className="size-4 text-muted-foreground/50 shrink-0" />
|
||||
) : mode === "settings" ? (
|
||||
@@ -1561,18 +1480,15 @@ export function CommandMenu() {
|
||||
"theme"
|
||||
? "Search themes..."
|
||||
: mode ===
|
||||
"code-theme"
|
||||
? "Search code themes..."
|
||||
"accounts"
|
||||
? "Filter accounts..."
|
||||
: mode ===
|
||||
"accounts"
|
||||
? "Filter accounts..."
|
||||
"settings"
|
||||
? "Configuration..."
|
||||
: mode ===
|
||||
"settings"
|
||||
? "Configuration..."
|
||||
: mode ===
|
||||
"model"
|
||||
? "Search models..."
|
||||
: "Type a command..."
|
||||
"model"
|
||||
? "Search models..."
|
||||
: "Type a command..."
|
||||
}
|
||||
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground py-3 text-sm outline-none"
|
||||
/>
|
||||
@@ -2104,18 +2020,53 @@ export function CommandMenu() {
|
||||
) : mode === "theme" ? (
|
||||
/* Theme mode */
|
||||
<>
|
||||
{filteredDarkThemes.length >
|
||||
{/* Mode toggle at the top */}
|
||||
<div className="px-3 py-2 border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full px-2 py-1.5 rounded-md hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
toggleMode()
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{currentMode ===
|
||||
"dark" ? (
|
||||
<Moon className="size-3.5" />
|
||||
) : (
|
||||
<Sun className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{currentMode ===
|
||||
"dark"
|
||||
? "Dark"
|
||||
: "Light"}{" "}
|
||||
mode
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Click
|
||||
to
|
||||
toggle
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{filteredThemes.length >
|
||||
0 && (
|
||||
<CommandGroup title="Dark Themes">
|
||||
{filteredDarkThemes.map(
|
||||
<CommandGroup title="Themes">
|
||||
{filteredThemes.map(
|
||||
(
|
||||
theme,
|
||||
) => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
const isActive =
|
||||
darkThemeId ===
|
||||
currentThemeId ===
|
||||
theme.id;
|
||||
const variant =
|
||||
theme[
|
||||
currentMode
|
||||
];
|
||||
return (
|
||||
<CommandItemButton
|
||||
key={
|
||||
@@ -2139,79 +2090,14 @@ export function CommandMenu() {
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgPreview,
|
||||
variant.bgPreview,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentPreview,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[13px] text-foreground flex-1">
|
||||
{
|
||||
theme.name
|
||||
}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground hidden sm:block">
|
||||
{
|
||||
theme.description
|
||||
}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success shrink-0" />
|
||||
)}
|
||||
</CommandItemButton>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{filteredLightThemes.length >
|
||||
0 && (
|
||||
<CommandGroup title="Light Themes">
|
||||
{filteredLightThemes.map(
|
||||
(
|
||||
theme,
|
||||
) => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
const isActive =
|
||||
lightThemeId ===
|
||||
theme.id;
|
||||
return (
|
||||
<CommandItemButton
|
||||
key={
|
||||
theme.id
|
||||
}
|
||||
index={
|
||||
idx
|
||||
}
|
||||
selected={
|
||||
selectedIndex ===
|
||||
idx
|
||||
}
|
||||
onClick={() =>
|
||||
setColorTheme(
|
||||
theme.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgPreview,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentPreview,
|
||||
variant.accentPreview,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
@@ -2249,169 +2135,6 @@ export function CommandMenu() {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : mode === "code-theme" ? (
|
||||
/* Code theme mode */
|
||||
<>
|
||||
{(() => {
|
||||
const darkThemes =
|
||||
filteredCodeThemes.filter(
|
||||
(
|
||||
t,
|
||||
) =>
|
||||
t.mode ===
|
||||
"dark",
|
||||
);
|
||||
const lightThemes =
|
||||
filteredCodeThemes.filter(
|
||||
(
|
||||
t,
|
||||
) =>
|
||||
t.mode ===
|
||||
"light",
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{darkThemes.length >
|
||||
0 && (
|
||||
<CommandGroup title="Dark">
|
||||
{darkThemes.map(
|
||||
(
|
||||
theme,
|
||||
) => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
const isActive =
|
||||
codeThemeDark ===
|
||||
theme.id;
|
||||
return (
|
||||
<CommandItemButton
|
||||
key={
|
||||
theme.id
|
||||
}
|
||||
index={
|
||||
idx
|
||||
}
|
||||
selected={
|
||||
selectedIndex ===
|
||||
idx
|
||||
}
|
||||
onClick={() =>
|
||||
setCodeThemeDark(
|
||||
theme.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgColor,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentColor,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[13px] text-foreground flex-1">
|
||||
{
|
||||
theme.name
|
||||
}
|
||||
</span>
|
||||
<Moon className="size-2.5 text-muted-foreground shrink-0" />
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success shrink-0" />
|
||||
)}
|
||||
</CommandItemButton>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{lightThemes.length >
|
||||
0 && (
|
||||
<CommandGroup title="Light">
|
||||
{lightThemes.map(
|
||||
(
|
||||
theme,
|
||||
) => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
const isActive =
|
||||
codeThemeLight ===
|
||||
theme.id;
|
||||
return (
|
||||
<CommandItemButton
|
||||
key={
|
||||
theme.id
|
||||
}
|
||||
index={
|
||||
idx
|
||||
}
|
||||
selected={
|
||||
selectedIndex ===
|
||||
idx
|
||||
}
|
||||
onClick={() =>
|
||||
setCodeThemeLight(
|
||||
theme.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgColor,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border/40"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentColor,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[13px] text-foreground flex-1">
|
||||
{
|
||||
theme.name
|
||||
}
|
||||
</span>
|
||||
<Sun className="size-2.5 text-muted-foreground shrink-0" />
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success shrink-0" />
|
||||
)}
|
||||
</CommandItemButton>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{hasQuery &&
|
||||
filteredCodeThemes.length ===
|
||||
0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground/70">
|
||||
No
|
||||
code
|
||||
themes
|
||||
match
|
||||
"
|
||||
{
|
||||
search
|
||||
}
|
||||
"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : mode === "accounts" ? (
|
||||
/* Accounts mode */
|
||||
<>
|
||||
@@ -2904,7 +2627,7 @@ export function CommandMenu() {
|
||||
t,
|
||||
) =>
|
||||
t.id ===
|
||||
colorTheme,
|
||||
currentThemeId,
|
||||
)
|
||||
?.name ??
|
||||
"Theme"}
|
||||
@@ -2913,46 +2636,6 @@ export function CommandMenu() {
|
||||
</CommandItemButton>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
const activeDarkTheme =
|
||||
BUILT_IN_THEMES.find(
|
||||
(
|
||||
t,
|
||||
) =>
|
||||
t.id ===
|
||||
codeThemeDark,
|
||||
);
|
||||
return (
|
||||
<CommandItemButton
|
||||
key="settings-code-theme"
|
||||
index={
|
||||
idx
|
||||
}
|
||||
selected={
|
||||
selectedIndex ===
|
||||
idx
|
||||
}
|
||||
onClick={() =>
|
||||
switchMode(
|
||||
"code-theme",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Code className="size-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="text-[13px] text-foreground flex-1">
|
||||
Code
|
||||
Theme
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground hidden sm:block">
|
||||
{activeDarkTheme?.name ??
|
||||
codeThemeDark}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/30 shrink-0" />
|
||||
</CommandItemButton>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const idx =
|
||||
getNextIndex();
|
||||
|
||||
@@ -3,6 +3,7 @@ import Image from "next/image";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { CollapsibleBody } from "@/components/issue/collapsible-body";
|
||||
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
|
||||
@@ -183,10 +184,12 @@ function DescriptionBlock({ entry }: { entry: DescriptionEntry }) {
|
||||
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
@@ -254,10 +257,12 @@ function CommentBlock({ comment }: { comment: DiscussionComment }) {
|
||||
|
||||
const renderedBody = comment.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: comment.bodyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: comment.bodyHtml }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
@@ -346,10 +351,12 @@ function CommentBlock({ comment }: { comment: DiscussionComment }) {
|
||||
function ReplyBlock({ reply }: { reply: DiscussionReply }) {
|
||||
const renderedBody = reply.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: reply.bodyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: reply.bodyHtml }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
|
||||
@@ -275,10 +276,12 @@ function ThreadEntry({
|
||||
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
@@ -502,10 +505,12 @@ function ThreadComment({
|
||||
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useGlobalChat, type InlineContext } from "@/components/shared/global-ch
|
||||
import { CommitDialog } from "@/components/shared/commit-dialog";
|
||||
import { commitFileEdit } from "@/app/(app)/repos/[owner]/[repo]/blob/blob-actions";
|
||||
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
|
||||
import { useColorTheme } from "@/components/theme/theme-provider";
|
||||
import { highlightCodeClient } from "@/lib/shiki-client";
|
||||
|
||||
interface CodeViewerClientProps {
|
||||
html: string;
|
||||
@@ -114,6 +116,32 @@ export function CodeViewerClient({
|
||||
branch,
|
||||
}: CodeViewerClientProps) {
|
||||
const { addCodeContext } = useGlobalChat();
|
||||
const { themeId } = useColorTheme();
|
||||
|
||||
// Theme-reactive highlighting: use server HTML initially, re-highlight on theme change
|
||||
const [currentHtml, setCurrentHtml] = useState(html);
|
||||
const initialThemeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialThemeRef.current === null) {
|
||||
initialThemeRef.current = themeId;
|
||||
return;
|
||||
}
|
||||
if (themeId === initialThemeRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
highlightCodeClient(content, language, themeId).then((newHtml) => {
|
||||
if (!cancelled) {
|
||||
setCurrentHtml(newHtml);
|
||||
initialThemeRef.current = themeId;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [themeId, content, language]);
|
||||
|
||||
const codeRef = useRef<HTMLDivElement>(null);
|
||||
const [toolbarPos, setToolbarPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedRange, setSelectedRange] = useState<{
|
||||
@@ -169,7 +197,7 @@ export function CodeViewerClient({
|
||||
lines.forEach((el, i) => {
|
||||
el.id = `L${i + 1}`;
|
||||
});
|
||||
}, [html]);
|
||||
}, [currentHtml]);
|
||||
|
||||
// Apply highlight classes when highlightedLines changes
|
||||
useEffect(() => {
|
||||
@@ -187,7 +215,7 @@ export function CodeViewerClient({
|
||||
el.classList.remove("line-highlighted");
|
||||
}
|
||||
});
|
||||
}, [highlightedLines, html]);
|
||||
}, [highlightedLines, currentHtml]);
|
||||
|
||||
// Read hash on mount and scroll to line
|
||||
useEffect(() => {
|
||||
@@ -977,7 +1005,9 @@ export function CodeViewerClient({
|
||||
>
|
||||
<div
|
||||
className="code-content"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentHtml,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useMutationEvents } from "@/components/shared/mutation-event-provider";
|
||||
import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events";
|
||||
import { useReadme } from "@/hooks/use-readme";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import { ReadmeToolbar } from "@/components/repo/readme-toolbar";
|
||||
import {
|
||||
fetchReadmeMarkdown,
|
||||
@@ -1065,12 +1066,14 @@ export function RepoOverview({
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readmeHtml,
|
||||
}}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readmeHtml,
|
||||
}}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1194,12 +1197,14 @@ export function RepoOverview({
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readmeHtml,
|
||||
}}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readmeHtml,
|
||||
}}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ExternalLink, ShieldAlert, User } from "lucide-react";
|
||||
import { ArrowLeft, ExternalLink, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import type { SecurityAdvisoryDetail } from "@/lib/github";
|
||||
|
||||
function severityColor(severity: string | null): {
|
||||
@@ -165,12 +166,14 @@ export function AdvisoryDetail({
|
||||
<div className="px-4 py-2.5 border-b border-border text-[11px] font-mono uppercase tracking-wider text-muted-foreground/60">
|
||||
Description
|
||||
</div>
|
||||
<div
|
||||
className="ghmd px-4 py-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: descriptionHtml,
|
||||
}}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd px-4 py-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: descriptionHtml,
|
||||
}}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</section>
|
||||
) : (
|
||||
<section className="border border-border">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bug, ExternalLink, FileText, KeyRound, ShieldAlert } from "lucide-react
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
|
||||
interface Advisory {
|
||||
ghsaId: string;
|
||||
@@ -587,10 +588,14 @@ function PolicySection({
|
||||
{/* Scrollable content */}
|
||||
{policyHtml ? (
|
||||
<div className="overflow-y-auto min-h-0 flex-1 px-4 py-4">
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: policyHtml }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: policyHtml,
|
||||
}}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-16 text-center">
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Check, Moon, Sun, Upload, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCodeTheme } from "@/components/theme/code-theme-provider";
|
||||
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
|
||||
import { CODE_FONTS } from "@/lib/code-themes/fonts";
|
||||
import type { CodeThemeOption } from "@/lib/code-themes/types";
|
||||
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
|
||||
import { HighlightedCodeBlock } from "@/components/shared/highlighted-code-block";
|
||||
|
||||
const SAMPLE_CODE = `function fibonacci(n: number): number {
|
||||
@@ -18,244 +10,18 @@ const SAMPLE_CODE = `function fibonacci(n: number): number {
|
||||
const result = fibonacci(10);
|
||||
console.log("Result:", result);`;
|
||||
|
||||
interface CustomThemeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: "dark" | "light";
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export function EditorTab() {
|
||||
const {
|
||||
codeThemeDark,
|
||||
codeThemeLight,
|
||||
codeFont,
|
||||
codeFontSize,
|
||||
setCodeThemeDark,
|
||||
setCodeThemeLight,
|
||||
setCodeFont,
|
||||
setCodeFontSize,
|
||||
} = useCodeTheme();
|
||||
const { emit } = useMutationEvents();
|
||||
|
||||
const [customThemes, setCustomThemes] = useState<CustomThemeItem[]>([]);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
// Load custom themes on mount
|
||||
if (!loadedRef.current) {
|
||||
loadedRef.current = true;
|
||||
fetch("/api/code-themes")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((themes: CustomThemeItem[]) => setCustomThemes(themes))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const darkThemes = BUILT_IN_THEMES.filter((t) => t.mode === "dark");
|
||||
const lightThemes = BUILT_IN_THEMES.filter((t) => t.mode === "light");
|
||||
const customDark = customThemes.filter((t) => t.mode === "dark");
|
||||
const customLight = customThemes.filter((t) => t.mode === "light");
|
||||
|
||||
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const res = await fetch("/api/code-themes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: json.name || file.name.replace(/\.json$/, ""),
|
||||
themeJson: json,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
.json()
|
||||
.catch(() => ({ error: "Import failed" }));
|
||||
setImportError(err.error || "Import failed");
|
||||
} else {
|
||||
const theme = await res.json();
|
||||
setCustomThemes((prev) => [theme, ...prev]);
|
||||
emit({ type: "code-theme:created" });
|
||||
}
|
||||
} catch {
|
||||
setImportError("Invalid JSON file");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCustom(id: string) {
|
||||
const res = await fetch("/api/code-themes", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setCustomThemes((prev) => prev.filter((t) => t.id !== id));
|
||||
// Reset selection if deleted theme was active
|
||||
if (codeThemeDark === id) setCodeThemeDark("vitesse-black");
|
||||
if (codeThemeLight === id) setCodeThemeLight("vitesse-light");
|
||||
emit({ type: "code-theme:deleted" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border overflow-y-auto max-h-[calc(100dvh-220px)]">
|
||||
{/* Dark Code Theme */}
|
||||
{/* Syntax Highlighting Info */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Dark Code Theme
|
||||
Syntax Highlighting
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Syntax highlighting theme used when the UI is in dark mode.
|
||||
Code syntax highlighting is derived from your app theme.
|
||||
Change your theme to change the code colors.
|
||||
</p>
|
||||
<ThemeGrid
|
||||
themes={[...darkThemes, ...customDark.map(toOption)]}
|
||||
activeId={codeThemeDark}
|
||||
onSelect={setCodeThemeDark}
|
||||
onDelete={(id) =>
|
||||
customDark.some((c) => c.id === id) &&
|
||||
handleDeleteCustom(id)
|
||||
}
|
||||
customIds={new Set(customDark.map((c) => c.id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Light Code Theme */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Light Code Theme
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Syntax highlighting theme used when the UI is in light mode.
|
||||
</p>
|
||||
<ThemeGrid
|
||||
themes={[...lightThemes, ...customLight.map(toOption)]}
|
||||
activeId={codeThemeLight}
|
||||
onSelect={setCodeThemeLight}
|
||||
onDelete={(id) =>
|
||||
customLight.some((c) => c.id === id) &&
|
||||
handleDeleteCustom(id)
|
||||
}
|
||||
customIds={new Set(customLight.map((c) => c.id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import Custom Theme */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Import Custom Theme
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Import a VS Code compatible theme JSON file.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono border border-border",
|
||||
"hover:bg-muted/50 transition-colors cursor-pointer",
|
||||
importing &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Upload className="size-3" />
|
||||
{importing ? "Importing..." : "Choose File"}
|
||||
</button>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="text-[11px] text-destructive mt-2">
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Font */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Code Font
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Font used in code blocks, file viewer, and diffs.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{CODE_FONTS.map((font) => {
|
||||
const isActive = codeFont === font.id;
|
||||
return (
|
||||
<button
|
||||
key={font.id}
|
||||
onClick={() => setCodeFont(font.id)}
|
||||
className={cn(
|
||||
"group relative flex flex-col border px-3 py-2.5 text-left transition-colors cursor-pointer",
|
||||
isActive
|
||||
? "border-foreground/30 bg-muted/50 dark:bg-white/[0.04]"
|
||||
: "border-border hover:border-foreground/10 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono font-medium text-foreground">
|
||||
{font.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground mt-1 truncate"
|
||||
style={{
|
||||
fontFamily: font.family,
|
||||
}}
|
||||
>
|
||||
{`const x = 42; // preview`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Font Size
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Code font size in pixels.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={20}
|
||||
step={1}
|
||||
value={codeFontSize}
|
||||
onChange={(e) =>
|
||||
setCodeFontSize(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 accent-foreground"
|
||||
/>
|
||||
<span className="text-xs font-mono text-muted-foreground w-8 text-right">
|
||||
{codeFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
@@ -266,15 +32,14 @@ export function EditorTab() {
|
||||
<div
|
||||
className="mt-2 border border-border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"var(--code-theme-bg, var(--code-bg))",
|
||||
backgroundColor: "var(--code-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="code-content p-3 overflow-x-auto [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:!bg-transparent [&_code]:!bg-transparent"
|
||||
style={{
|
||||
fontFamily: "var(--code-font-override, var(--font-code)), ui-monospace, monospace",
|
||||
fontSize: `var(--code-font-size, 13px)`,
|
||||
fontFamily: "var(--font-code), ui-monospace, monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "20px",
|
||||
}}
|
||||
>
|
||||
@@ -288,105 +53,3 @@ export function EditorTab() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toOption(ct: CustomThemeItem): CodeThemeOption {
|
||||
return {
|
||||
id: ct.id,
|
||||
name: ct.name,
|
||||
type: "custom",
|
||||
mode: ct.mode,
|
||||
bgColor: ct.bgColor,
|
||||
fgColor: ct.fgColor,
|
||||
accentColor: ct.accentColor,
|
||||
};
|
||||
}
|
||||
|
||||
function ThemeGrid({
|
||||
themes,
|
||||
activeId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
customIds,
|
||||
}: {
|
||||
themes: CodeThemeOption[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
customIds: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{themes.map((theme) => {
|
||||
const isActive = activeId === theme.id;
|
||||
const isCustom = customIds.has(theme.id);
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 border px-3 py-2.5 text-left transition-colors cursor-pointer",
|
||||
isActive
|
||||
? "border-foreground/30 bg-muted/50 dark:bg-white/[0.04]"
|
||||
: "border-border hover:border-foreground/10 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{/* Color preview dots */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-border/60"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgColor,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-border/60"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-mono font-medium text-foreground truncate">
|
||||
{theme.name}
|
||||
</span>
|
||||
{theme.mode === "dark" ? (
|
||||
<Moon className="size-2.5 text-muted-foreground/50 shrink-0" />
|
||||
) : (
|
||||
<Sun className="size-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success" />
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(theme.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,16 +14,19 @@ interface GeneralTabProps {
|
||||
function ThemeGrid({
|
||||
themes,
|
||||
activeId,
|
||||
mode,
|
||||
onSelect,
|
||||
}: {
|
||||
themes: ThemeDefinition[];
|
||||
activeId: string;
|
||||
mode: "dark" | "light";
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{themes.map((theme) => {
|
||||
const isActive = activeId === theme.id;
|
||||
const variant = theme[mode];
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
@@ -35,25 +38,23 @@ function ThemeGrid({
|
||||
: "border-border hover:border-foreground/10 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{/* Color preview dots */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-border/60"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.bgPreview,
|
||||
variant.bgPreview,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-border/60"
|
||||
style={{
|
||||
backgroundColor:
|
||||
theme.accentPreview,
|
||||
variant.accentPreview,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-mono font-medium text-foreground">
|
||||
@@ -65,7 +66,6 @@ function ThemeGrid({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Check */}
|
||||
{isActive && (
|
||||
<Check className="size-3.5 text-success shrink-0" />
|
||||
)}
|
||||
@@ -77,40 +77,71 @@ function ThemeGrid({
|
||||
}
|
||||
|
||||
export function GeneralTab({ settings: _settings, onUpdate: _onUpdate }: GeneralTabProps) {
|
||||
const { setColorTheme, darkThemes, lightThemes, darkThemeId, lightThemeId } =
|
||||
useColorTheme();
|
||||
const { themeId, mode, setTheme, toggleMode, themes } = useColorTheme();
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Dark themes */}
|
||||
{/* Mode toggle */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Moon className="size-3" />
|
||||
Dark Theme
|
||||
{mode === "dark" ? (
|
||||
<Moon className="size-3" />
|
||||
) : (
|
||||
<Sun className="size-3" />
|
||||
)}
|
||||
Appearance Mode
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Used when dark mode is active.
|
||||
Toggle between dark and light mode.
|
||||
</p>
|
||||
<ThemeGrid
|
||||
themes={darkThemes}
|
||||
activeId={darkThemeId}
|
||||
onSelect={setColorTheme}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => mode === "light" && toggleMode()}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 border text-sm transition-colors",
|
||||
mode === "dark"
|
||||
? "border-foreground/30 bg-muted/50"
|
||||
: "border-border hover:border-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Moon className="size-4" />
|
||||
<span>Dark</span>
|
||||
{mode === "dark" && (
|
||||
<Check className="size-3.5 text-success" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mode === "dark" && toggleMode()}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 border text-sm transition-colors",
|
||||
mode === "light"
|
||||
? "border-foreground/30 bg-muted/50"
|
||||
: "border-border hover:border-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
<span>Light</span>
|
||||
{mode === "light" && (
|
||||
<Check className="size-3.5 text-success" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light themes */}
|
||||
{/* Theme selection */}
|
||||
<div className="px-4 py-4">
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sun className="size-3" />
|
||||
Light Theme
|
||||
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Theme
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
|
||||
Used when light mode is active.
|
||||
Choose a color theme. Each theme has both dark and light
|
||||
variants.
|
||||
</p>
|
||||
<ThemeGrid
|
||||
themes={lightThemes}
|
||||
activeId={lightThemeId}
|
||||
onSelect={setColorTheme}
|
||||
themes={themes}
|
||||
activeId={themeId}
|
||||
mode={mode}
|
||||
onSelect={setTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, memo } from "react";
|
||||
import type { Highlighter, BundledLanguage } from "shiki";
|
||||
|
||||
let highlighterInstance: Highlighter | null = null;
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getClientHighlighter(): Promise<Highlighter> {
|
||||
if (highlighterInstance) return Promise.resolve(highlighterInstance);
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = import("shiki")
|
||||
.then(({ createHighlighter }) =>
|
||||
createHighlighter({
|
||||
themes: ["vitesse-light", "vitesse-black"],
|
||||
langs: [],
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
highlighterInstance = h;
|
||||
return h;
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
import { useColorTheme } from "@/components/theme/theme-provider";
|
||||
import { highlightCodeClient } from "@/lib/shiki-client";
|
||||
|
||||
export const HighlightedCodeBlock = memo(function HighlightedCodeBlock({
|
||||
code,
|
||||
@@ -31,50 +11,18 @@ export const HighlightedCodeBlock = memo(function HighlightedCodeBlock({
|
||||
code: string;
|
||||
lang: string;
|
||||
}) {
|
||||
const { themeId } = useColorTheme();
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const highlighter = await getClientHighlighter();
|
||||
const loaded = highlighter.getLoadedLanguages();
|
||||
let effectiveLang = lang;
|
||||
if (!loaded.includes(lang)) {
|
||||
try {
|
||||
await highlighter.loadLanguage(
|
||||
lang as BundledLanguage,
|
||||
);
|
||||
} catch {
|
||||
effectiveLang = "text";
|
||||
if (!loaded.includes("text")) {
|
||||
try {
|
||||
await highlighter.loadLanguage(
|
||||
"text" as BundledLanguage,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
const result = highlighter.codeToHtml(code, {
|
||||
lang: effectiveLang,
|
||||
themes: {
|
||||
light: "vitesse-light",
|
||||
dark: "vitesse-black",
|
||||
},
|
||||
defaultColor: false,
|
||||
});
|
||||
setHtml(result);
|
||||
}
|
||||
} catch {
|
||||
// silently fall back to plain text
|
||||
}
|
||||
})();
|
||||
highlightCodeClient(code, lang, themeId).then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
}, [code, lang, themeId]);
|
||||
|
||||
if (html) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
|
||||
@@ -59,6 +59,7 @@ const sanitizeSchema: typeof defaultSchema = {
|
||||
import { highlightCode } from "@/lib/shiki";
|
||||
import { toInternalUrl } from "@/lib/github-utils";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
|
||||
interface RepoContext {
|
||||
owner: string;
|
||||
@@ -366,6 +367,15 @@ function escapeHtml(str: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeDataAttr(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
function buildInstallTabsHtml(variants: PkgVariant[], id: number): string {
|
||||
const name = `pkgtab-${id}`;
|
||||
let html = '<div class="ghmd-pkg-tabs">';
|
||||
@@ -425,12 +435,15 @@ export async function renderMarkdownToHtml(
|
||||
const renderedBlocks = await Promise.all(
|
||||
codeBlocks.map(async (block) => ({
|
||||
id: block.id,
|
||||
code: block.code,
|
||||
lang: block.lang,
|
||||
html: await highlightCode(block.code, block.lang),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const block of renderedBlocks) {
|
||||
html = html.replace(`<div data-code-block="${block.id}"></div>`, block.html);
|
||||
const wrappedHtml = `<div class="ghmd-reactive-code" data-code="${escapeDataAttr(block.code)}" data-lang="${escapeHtml(block.lang)}">${block.html}</div>`;
|
||||
html = html.replace(`<div data-code-block="${block.id}"></div>`, wrappedHtml);
|
||||
}
|
||||
|
||||
for (const block of installBlocks) {
|
||||
@@ -486,10 +499,12 @@ export async function MarkdownRenderer({
|
||||
|
||||
return (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className={`ghmd ${className || ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className={`ghmd ${className || ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useColorTheme } from "@/components/theme/theme-provider";
|
||||
import { highlightCodeClient } from "@/lib/shiki-client";
|
||||
|
||||
interface ReactiveCodeBlocksProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ReactiveCodeBlocks({ children }: ReactiveCodeBlocksProps) {
|
||||
const { themeId } = useColorTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const initialThemeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (initialThemeRef.current === null) {
|
||||
initialThemeRef.current = themeId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialThemeRef.current === themeId) return;
|
||||
initialThemeRef.current = themeId;
|
||||
|
||||
const codeWrappers = containerRef.current.querySelectorAll(".ghmd-reactive-code");
|
||||
if (codeWrappers.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const rehighlight = async () => {
|
||||
for (const wrapper of codeWrappers) {
|
||||
if (cancelled) break;
|
||||
|
||||
const code = wrapper.getAttribute("data-code");
|
||||
const lang = wrapper.getAttribute("data-lang") || "text";
|
||||
|
||||
if (!code) continue;
|
||||
|
||||
const decodedCode = code
|
||||
.replace(/ /g, "\n")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&");
|
||||
|
||||
try {
|
||||
const html = await highlightCodeClient(
|
||||
decodedCode,
|
||||
lang,
|
||||
themeId,
|
||||
);
|
||||
if (!cancelled) {
|
||||
wrapper.innerHTML = html;
|
||||
}
|
||||
} catch {
|
||||
// Keep existing content on error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rehighlight();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [themeId]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_CODE_THEME_LIGHT,
|
||||
DEFAULT_CODE_THEME_DARK,
|
||||
DEFAULT_CODE_FONT,
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
} from "@/lib/code-themes/types";
|
||||
import type { CodeThemePreferences } from "@/lib/code-themes/types";
|
||||
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
|
||||
import { CODE_FONTS } from "@/lib/code-themes/fonts";
|
||||
import type { CodeFontOption } from "@/lib/code-themes/fonts";
|
||||
|
||||
const LS_KEY = "code-theme-prefs";
|
||||
const COOKIE_KEY = "code-theme-prefs";
|
||||
|
||||
interface CodeThemeContextValue {
|
||||
codeThemeDark: string;
|
||||
codeThemeLight: string;
|
||||
codeFont: string;
|
||||
codeFontSize: number;
|
||||
setCodeThemeDark: (id: string) => void;
|
||||
setCodeThemeLight: (id: string) => void;
|
||||
setCodeFont: (id: string) => void;
|
||||
setCodeFontSize: (size: number) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<CodeThemeContextValue | null>(null);
|
||||
|
||||
export function useCodeTheme(): CodeThemeContextValue {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useCodeTheme must be used within CodeThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function readLocalPrefs(): CodeThemePreferences {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark: DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: DEFAULT_CODE_FONT,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw)
|
||||
return {
|
||||
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark: DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: DEFAULT_CODE_FONT,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
const p = JSON.parse(raw);
|
||||
return {
|
||||
codeThemeLight: p.light ?? DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark: p.dark ?? DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: p.fontId ?? DEFAULT_CODE_FONT,
|
||||
codeFontSize: p.fontSize ?? DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark: DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: DEFAULT_CODE_FONT,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalPrefs(prefs: CodeThemePreferences) {
|
||||
const bgDark = BUILT_IN_THEMES.find((t) => t.id === prefs.codeThemeDark)?.bgColor;
|
||||
const bgLight = BUILT_IN_THEMES.find((t) => t.id === prefs.codeThemeLight)?.bgColor;
|
||||
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
|
||||
|
||||
// Store extra fields for FOUC script
|
||||
const val = JSON.stringify({
|
||||
light: prefs.codeThemeLight,
|
||||
dark: prefs.codeThemeDark,
|
||||
fontId: prefs.codeFont,
|
||||
fontSize: prefs.codeFontSize,
|
||||
// For the FOUC prevention script — use the dark bg by default
|
||||
bg: bgDark ?? bgLight,
|
||||
font: fontDef?.family ?? "",
|
||||
});
|
||||
localStorage.setItem(LS_KEY, val);
|
||||
}
|
||||
|
||||
function setCookie(prefs: CodeThemePreferences) {
|
||||
const value = JSON.stringify({
|
||||
light: prefs.codeThemeLight,
|
||||
dark: prefs.codeThemeDark,
|
||||
font: prefs.codeFont,
|
||||
fontSize: prefs.codeFontSize,
|
||||
});
|
||||
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
||||
}
|
||||
|
||||
function applyCSS(prefs: CodeThemePreferences) {
|
||||
const d = document.documentElement;
|
||||
const isDark = d.classList.contains("dark");
|
||||
|
||||
// Set code theme background based on active UI mode
|
||||
const activeThemeId = isDark ? prefs.codeThemeDark : prefs.codeThemeLight;
|
||||
const themeDef = BUILT_IN_THEMES.find((t) => t.id === activeThemeId);
|
||||
if (themeDef) {
|
||||
d.style.setProperty("--code-theme-bg", themeDef.bgColor);
|
||||
} else {
|
||||
d.style.removeProperty("--code-theme-bg");
|
||||
}
|
||||
|
||||
// Font override
|
||||
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
|
||||
if (fontDef && prefs.codeFont !== "default") {
|
||||
d.style.setProperty("--code-font-override", fontDef.family);
|
||||
} else {
|
||||
d.style.removeProperty("--code-font-override");
|
||||
}
|
||||
|
||||
// Font size
|
||||
d.style.setProperty("--code-font-size", `${prefs.codeFontSize}px`);
|
||||
}
|
||||
|
||||
function loadGoogleFont(font: CodeFontOption | undefined) {
|
||||
if (!font?.googleFontsUrl) return;
|
||||
const id = `code-font-${font.id}`;
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = font.googleFontsUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
export function CodeThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [prefs, setPrefs] = useState<CodeThemePreferences>(readLocalPrefs);
|
||||
const syncedFromDb = useRef(false);
|
||||
|
||||
// Apply CSS vars whenever prefs change
|
||||
useEffect(() => {
|
||||
applyCSS(prefs);
|
||||
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
|
||||
loadGoogleFont(fontDef);
|
||||
}, [prefs]);
|
||||
|
||||
// Re-apply when UI theme changes (dark/light toggle)
|
||||
useEffect(() => {
|
||||
let raf: number;
|
||||
const observer = new MutationObserver(() => {
|
||||
// Debounce with rAF to batch rapid class changes
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => applyCSS(prefs));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [prefs]);
|
||||
|
||||
// Sync from DB on mount
|
||||
useEffect(() => {
|
||||
if (syncedFromDb.current) return;
|
||||
syncedFromDb.current = true;
|
||||
|
||||
fetch("/api/user-settings")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => {
|
||||
if (!settings) return;
|
||||
const dbPrefs: CodeThemePreferences = {
|
||||
codeThemeLight:
|
||||
settings.codeThemeLight ?? DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark:
|
||||
settings.codeThemeDark ?? DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: settings.codeFont ?? DEFAULT_CODE_FONT,
|
||||
codeFontSize:
|
||||
settings.codeFontSize ?? DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
const local = readLocalPrefs();
|
||||
const changed =
|
||||
dbPrefs.codeThemeLight !== local.codeThemeLight ||
|
||||
dbPrefs.codeThemeDark !== local.codeThemeDark ||
|
||||
dbPrefs.codeFont !== local.codeFont ||
|
||||
dbPrefs.codeFontSize !== local.codeFontSize;
|
||||
if (changed) {
|
||||
writeLocalPrefs(dbPrefs);
|
||||
setCookie(dbPrefs);
|
||||
setPrefs(dbPrefs);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const persistUpdate = useCallback((newPrefs: CodeThemePreferences) => {
|
||||
writeLocalPrefs(newPrefs);
|
||||
setCookie(newPrefs);
|
||||
setPrefs(newPrefs);
|
||||
|
||||
// Persist to DB in background
|
||||
fetch("/api/user-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
codeThemeLight: newPrefs.codeThemeLight,
|
||||
codeThemeDark: newPrefs.codeThemeDark,
|
||||
codeFont: newPrefs.codeFont,
|
||||
codeFontSize: newPrefs.codeFontSize,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setCodeThemeDark = useCallback(
|
||||
(id: string) => persistUpdate({ ...prefs, codeThemeDark: id }),
|
||||
[prefs, persistUpdate],
|
||||
);
|
||||
const setCodeThemeLight = useCallback(
|
||||
(id: string) => persistUpdate({ ...prefs, codeThemeLight: id }),
|
||||
[prefs, persistUpdate],
|
||||
);
|
||||
const setCodeFont = useCallback(
|
||||
(id: string) => persistUpdate({ ...prefs, codeFont: id }),
|
||||
[prefs, persistUpdate],
|
||||
);
|
||||
const setCodeFontSize = useCallback(
|
||||
(size: number) => persistUpdate({ ...prefs, codeFontSize: size }),
|
||||
[prefs, persistUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Ctx.Provider
|
||||
value={{
|
||||
codeThemeDark: prefs.codeThemeDark,
|
||||
codeThemeLight: prefs.codeThemeLight,
|
||||
codeFont: prefs.codeFont,
|
||||
codeFontSize: prefs.codeFontSize,
|
||||
setCodeThemeDark,
|
||||
setCodeThemeLight,
|
||||
setCodeFont,
|
||||
setCodeFontSize,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -6,32 +6,25 @@ import {
|
||||
applyTheme,
|
||||
getTheme,
|
||||
listThemes,
|
||||
listDarkThemes,
|
||||
listLightThemes,
|
||||
migrateLegacyThemeId,
|
||||
STORAGE_KEY,
|
||||
DARK_THEME_KEY,
|
||||
LIGHT_THEME_KEY,
|
||||
DARK_THEME_ID,
|
||||
LIGHT_THEME_ID,
|
||||
MODE_KEY,
|
||||
DEFAULT_THEME_ID,
|
||||
DEFAULT_MODE,
|
||||
type ThemeDefinition,
|
||||
} from "@/lib/themes";
|
||||
|
||||
interface ColorThemeContext {
|
||||
/** Currently active theme id */
|
||||
colorTheme: string;
|
||||
/** Set a specific theme (also updates the dark/light preference for that mode) */
|
||||
setColorTheme: (id: string) => void;
|
||||
/** Toggle between dark and light mode (switches to the preferred theme for that mode). Pass a MouseEvent for a circular reveal from the click point. */
|
||||
themeId: string;
|
||||
/** Current mode (dark/light) */
|
||||
mode: "dark" | "light";
|
||||
/** Set a specific theme */
|
||||
setTheme: (id: string) => void;
|
||||
/** Toggle between dark and light mode */
|
||||
toggleMode: (e?: { clientX: number; clientY: number }) => void;
|
||||
/** All themes */
|
||||
themes: ThemeDefinition[];
|
||||
darkThemes: ThemeDefinition[];
|
||||
lightThemes: ThemeDefinition[];
|
||||
/** The preferred dark theme id */
|
||||
darkThemeId: string;
|
||||
/** The preferred light theme id */
|
||||
lightThemeId: string;
|
||||
mode: "dark" | "light";
|
||||
}
|
||||
|
||||
const Ctx = createContext<ColorThemeContext | null>(null);
|
||||
@@ -42,89 +35,83 @@ export function useColorTheme(): ColorThemeContext {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function getStored(key: string, fallback: string): string {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
return localStorage.getItem(key) ?? fallback;
|
||||
const THEME_COOKIE_KEY = "color-theme";
|
||||
const MODE_COOKIE_KEY = "color-mode";
|
||||
|
||||
function setThemeCookies(themeId: string, mode: "dark" | "light") {
|
||||
const maxAge = 365 * 24 * 60 * 60;
|
||||
document.cookie = `${THEME_COOKIE_KEY}=${encodeURIComponent(themeId)};path=/;max-age=${maxAge};samesite=lax`;
|
||||
document.cookie = `${MODE_COOKIE_KEY}=${mode};path=/;max-age=${maxAge};samesite=lax`;
|
||||
}
|
||||
|
||||
/** Pick the initial active theme: localStorage → system preference → dark fallback */
|
||||
function getInitialTheme(): string {
|
||||
if (typeof window === "undefined") return DARK_THEME_ID;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
// Trust the stored value - the inline script already validated it.
|
||||
// This prevents hydration mismatches where getTheme() might not be ready.
|
||||
if (stored) return stored;
|
||||
function getStoredPreferences(): { themeId: string; mode: "dark" | "light" } {
|
||||
if (typeof window === "undefined") {
|
||||
return { themeId: DEFAULT_THEME_ID, mode: DEFAULT_MODE };
|
||||
}
|
||||
|
||||
const storedTheme = localStorage.getItem(STORAGE_KEY);
|
||||
const storedMode = localStorage.getItem(MODE_KEY) as "dark" | "light" | null;
|
||||
|
||||
if (storedTheme && storedMode && getTheme(storedTheme)) {
|
||||
return { themeId: storedTheme, mode: storedMode };
|
||||
}
|
||||
|
||||
if (storedTheme) {
|
||||
const migration = migrateLegacyThemeId(storedTheme);
|
||||
if (migration) {
|
||||
localStorage.setItem(STORAGE_KEY, migration.themeId);
|
||||
localStorage.setItem(MODE_KEY, migration.mode);
|
||||
return migration;
|
||||
}
|
||||
if (getTheme(storedTheme)) {
|
||||
const mode =
|
||||
storedMode ??
|
||||
(window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light");
|
||||
localStorage.setItem(MODE_KEY, mode);
|
||||
return { themeId: storedTheme, mode };
|
||||
}
|
||||
}
|
||||
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
|
||||
const darkPref = localStorage.getItem(DARK_THEME_KEY) ?? DARK_THEME_ID;
|
||||
const lightPref = localStorage.getItem(LIGHT_THEME_KEY) ?? LIGHT_THEME_ID;
|
||||
const id = prefersDark ? darkPref : lightPref;
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
return id;
|
||||
const mode = prefersDark ? "dark" : "light";
|
||||
localStorage.setItem(STORAGE_KEY, DEFAULT_THEME_ID);
|
||||
localStorage.setItem(MODE_KEY, mode);
|
||||
return { themeId: DEFAULT_THEME_ID, mode };
|
||||
}
|
||||
|
||||
export function ColorThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { setTheme } = useTheme();
|
||||
const [colorTheme, setColorThemeState] = useState(getInitialTheme);
|
||||
const [darkThemeId, setDarkThemeId] = useState(() =>
|
||||
getStored(DARK_THEME_KEY, DARK_THEME_ID),
|
||||
);
|
||||
const [lightThemeId, setLightThemeId] = useState(() =>
|
||||
getStored(LIGHT_THEME_KEY, LIGHT_THEME_ID),
|
||||
);
|
||||
const syncedFromDb = useRef(false);
|
||||
const appliedRef = useRef<string | null>(null);
|
||||
const { setTheme: setNextTheme } = useTheme();
|
||||
const [themeId, setThemeIdState] = useState(DEFAULT_THEME_ID);
|
||||
const [mode, setModeState] = useState<"dark" | "light">(DEFAULT_MODE);
|
||||
const syncedToDb = useRef(false);
|
||||
|
||||
const themes = listThemes();
|
||||
const darkThemes = listDarkThemes();
|
||||
const lightThemes = listLightThemes();
|
||||
const currentTheme = getTheme(colorTheme);
|
||||
const mode = currentTheme?.mode ?? "dark";
|
||||
|
||||
// Apply CSS vars + dark/light class whenever colorTheme changes
|
||||
useEffect(() => {
|
||||
if (appliedRef.current === colorTheme) return;
|
||||
appliedRef.current = colorTheme;
|
||||
applyTheme(colorTheme);
|
||||
const theme = getTheme(colorTheme);
|
||||
if (theme) setTheme(theme.mode);
|
||||
}, [colorTheme, setTheme]);
|
||||
const prefs = getStoredPreferences();
|
||||
setThemeIdState(prefs.themeId);
|
||||
setModeState(prefs.mode);
|
||||
|
||||
// Mark the initial theme as already applied (FOUC script handled it)
|
||||
useEffect(() => {
|
||||
appliedRef.current = colorTheme;
|
||||
applyTheme(prefs.themeId, prefs.mode);
|
||||
setThemeCookies(prefs.themeId, prefs.mode);
|
||||
setNextTheme(prefs.mode);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On mount: fetch theme from DB — if it differs from localStorage, adopt it
|
||||
useEffect(() => {
|
||||
if (syncedFromDb.current) return;
|
||||
syncedFromDb.current = true;
|
||||
if (syncedToDb.current) return;
|
||||
syncedToDb.current = true;
|
||||
|
||||
fetch("/api/user-settings")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => {
|
||||
if (!settings) return;
|
||||
// Sync dark/light preferences from DB
|
||||
if (settings.darkTheme && getTheme(settings.darkTheme)) {
|
||||
localStorage.setItem(DARK_THEME_KEY, settings.darkTheme);
|
||||
setDarkThemeId(settings.darkTheme);
|
||||
}
|
||||
if (settings.lightTheme && getTheme(settings.lightTheme)) {
|
||||
localStorage.setItem(LIGHT_THEME_KEY, settings.lightTheme);
|
||||
setLightThemeId(settings.lightTheme);
|
||||
}
|
||||
// Sync active theme from DB
|
||||
if (settings.colorTheme && getTheme(settings.colorTheme)) {
|
||||
const dbTheme = settings.colorTheme;
|
||||
const local =
|
||||
localStorage.getItem(STORAGE_KEY) ?? DARK_THEME_ID;
|
||||
if (dbTheme !== local) {
|
||||
localStorage.setItem(STORAGE_KEY, dbTheme);
|
||||
setColorThemeState(dbTheme);
|
||||
appliedRef.current = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
const prefs = getStoredPreferences();
|
||||
fetch("/api/user-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
colorTheme: prefs.themeId,
|
||||
colorMode: prefs.mode,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const applyWithTransition = useCallback(
|
||||
@@ -152,77 +139,57 @@ export function ColorThemeProvider({ children }: { children: React.ReactNode })
|
||||
[],
|
||||
);
|
||||
|
||||
const setColorTheme = useCallback(
|
||||
const setTheme = useCallback(
|
||||
(id: string) => {
|
||||
const theme = getTheme(id);
|
||||
if (!theme) return;
|
||||
|
||||
applyWithTransition(() => {
|
||||
// Update the mode-specific preference
|
||||
if (theme.mode === "dark") {
|
||||
localStorage.setItem(DARK_THEME_KEY, id);
|
||||
setDarkThemeId(id);
|
||||
} else {
|
||||
localStorage.setItem(LIGHT_THEME_KEY, id);
|
||||
setLightThemeId(id);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
appliedRef.current = null;
|
||||
setColorThemeState(id);
|
||||
setThemeIdState(id);
|
||||
applyTheme(id, mode);
|
||||
setThemeCookies(id, mode);
|
||||
});
|
||||
|
||||
// Persist to DB in background
|
||||
fetch("/api/user-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ colorTheme: id }),
|
||||
body: JSON.stringify({ colorTheme: id, colorMode: mode }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
[applyWithTransition],
|
||||
[mode, applyWithTransition],
|
||||
);
|
||||
|
||||
const toggleMode = useCallback(
|
||||
(e?: { clientX: number; clientY: number }) => {
|
||||
const nextId = mode === "dark" ? lightThemeId : darkThemeId;
|
||||
const theme = getTheme(nextId);
|
||||
if (!theme) return;
|
||||
|
||||
const nextMode = mode === "dark" ? "light" : "dark";
|
||||
const coords = e ? { x: e.clientX, y: e.clientY } : undefined;
|
||||
|
||||
applyWithTransition(() => {
|
||||
if (theme.mode === "dark") {
|
||||
localStorage.setItem(DARK_THEME_KEY, nextId);
|
||||
setDarkThemeId(nextId);
|
||||
} else {
|
||||
localStorage.setItem(LIGHT_THEME_KEY, nextId);
|
||||
setLightThemeId(nextId);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, nextId);
|
||||
appliedRef.current = null;
|
||||
setColorThemeState(nextId);
|
||||
localStorage.setItem(MODE_KEY, nextMode);
|
||||
setModeState(nextMode);
|
||||
applyTheme(themeId, nextMode);
|
||||
setThemeCookies(themeId, nextMode);
|
||||
setNextTheme(nextMode);
|
||||
}, coords);
|
||||
|
||||
fetch("/api/user-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ colorTheme: nextId }),
|
||||
body: JSON.stringify({ colorTheme: themeId, colorMode: nextMode }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
[mode, darkThemeId, lightThemeId, applyWithTransition],
|
||||
[mode, themeId, applyWithTransition, setNextTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<Ctx.Provider
|
||||
value={{
|
||||
colorTheme,
|
||||
setColorTheme,
|
||||
themeId,
|
||||
mode,
|
||||
setTheme,
|
||||
toggleMode,
|
||||
themes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
darkThemeId,
|
||||
lightThemeId,
|
||||
mode,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,19 +8,21 @@ import { useColorTheme } from "./theme-provider";
|
||||
* Renders nothing — just a side-effect component.
|
||||
*/
|
||||
export function ThemeSync() {
|
||||
const { colorTheme } = useColorTheme();
|
||||
const prevRef = useRef(colorTheme);
|
||||
const { themeId, mode } = useColorTheme();
|
||||
const prevThemeRef = useRef(themeId);
|
||||
const prevModeRef = useRef(mode);
|
||||
|
||||
useEffect(() => {
|
||||
if (colorTheme === prevRef.current) return;
|
||||
prevRef.current = colorTheme;
|
||||
if (themeId === prevThemeRef.current && mode === prevModeRef.current) return;
|
||||
prevThemeRef.current = themeId;
|
||||
prevModeRef.current = mode;
|
||||
|
||||
fetch("/api/user-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ colorTheme }),
|
||||
body: JSON.stringify({ colorTheme: themeId, colorMode: mode }),
|
||||
}).catch(() => {});
|
||||
}, [colorTheme]);
|
||||
}, [themeId, mode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { CodeThemeOption } from "./types";
|
||||
|
||||
export const BUILT_IN_THEMES: CodeThemeOption[] = [
|
||||
// ── Dark themes ──
|
||||
{
|
||||
id: "vitesse-black",
|
||||
name: "Vitesse Black",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#000000",
|
||||
fgColor: "#dbd7ca",
|
||||
accentColor: "#4d9375",
|
||||
},
|
||||
{
|
||||
id: "github-dark",
|
||||
name: "GitHub Dark",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#0d1117",
|
||||
fgColor: "#e6edf3",
|
||||
accentColor: "#79c0ff",
|
||||
},
|
||||
{
|
||||
id: "github-dark-dimmed",
|
||||
name: "GitHub Dimmed",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#22272e",
|
||||
fgColor: "#adbac7",
|
||||
accentColor: "#6cb6ff",
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
name: "Dracula",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#282a36",
|
||||
fgColor: "#f8f8f2",
|
||||
accentColor: "#bd93f9",
|
||||
},
|
||||
{
|
||||
id: "one-dark-pro",
|
||||
name: "One Dark Pro",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#282c34",
|
||||
fgColor: "#abb2bf",
|
||||
accentColor: "#c678dd",
|
||||
},
|
||||
{
|
||||
id: "tokyo-night",
|
||||
name: "Tokyo Night",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#1a1b26",
|
||||
fgColor: "#a9b1d6",
|
||||
accentColor: "#7aa2f7",
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
name: "Nord",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#2e3440",
|
||||
fgColor: "#d8dee9",
|
||||
accentColor: "#88c0d0",
|
||||
},
|
||||
{
|
||||
id: "catppuccin-mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#1e1e2e",
|
||||
fgColor: "#cdd6f4",
|
||||
accentColor: "#cba6f7",
|
||||
},
|
||||
{
|
||||
id: "ayu-dark",
|
||||
name: "Ayu Dark",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#0b0e14",
|
||||
fgColor: "#bfbdb6",
|
||||
accentColor: "#e6b450",
|
||||
},
|
||||
{
|
||||
id: "monokai",
|
||||
name: "Monokai",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#272822",
|
||||
fgColor: "#f8f8f2",
|
||||
accentColor: "#f92672",
|
||||
},
|
||||
{
|
||||
id: "solarized-dark",
|
||||
name: "Solarized Dark",
|
||||
type: "built-in",
|
||||
mode: "dark",
|
||||
bgColor: "#002b36",
|
||||
fgColor: "#839496",
|
||||
accentColor: "#268bd2",
|
||||
},
|
||||
|
||||
// ── Light themes ──
|
||||
{
|
||||
id: "vitesse-light",
|
||||
name: "Vitesse Light",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#ffffff",
|
||||
fgColor: "#393a34",
|
||||
accentColor: "#1e754f",
|
||||
},
|
||||
{
|
||||
id: "github-light",
|
||||
name: "GitHub Light",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#ffffff",
|
||||
fgColor: "#1f2328",
|
||||
accentColor: "#0550ae",
|
||||
},
|
||||
{
|
||||
id: "one-light",
|
||||
name: "One Light",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#fafafa",
|
||||
fgColor: "#383a42",
|
||||
accentColor: "#a626a4",
|
||||
},
|
||||
{
|
||||
id: "catppuccin-latte",
|
||||
name: "Catppuccin Latte",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#eff1f5",
|
||||
fgColor: "#4c4f69",
|
||||
accentColor: "#8839ef",
|
||||
},
|
||||
{
|
||||
id: "solarized-light",
|
||||
name: "Solarized Light",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#fdf6e3",
|
||||
fgColor: "#657b83",
|
||||
accentColor: "#268bd2",
|
||||
},
|
||||
{
|
||||
id: "min-light",
|
||||
name: "Min Light",
|
||||
type: "built-in",
|
||||
mode: "light",
|
||||
bgColor: "#ffffff",
|
||||
fgColor: "#24292eff",
|
||||
accentColor: "#1a1a1a",
|
||||
},
|
||||
];
|
||||
|
||||
export function getBuiltInTheme(id: string): CodeThemeOption | undefined {
|
||||
return BUILT_IN_THEMES.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export function getBuiltInThemesByMode(mode: "dark" | "light"): CodeThemeOption[] {
|
||||
return BUILT_IN_THEMES.filter((t) => t.mode === mode);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { type CodeThemePreferences, DEFAULT_CODE_THEME_PREFS } from "./types";
|
||||
|
||||
export const CODE_THEME_COOKIE = "code-theme-prefs";
|
||||
|
||||
/**
|
||||
* Server-side: read code theme preferences from cookie.
|
||||
* Returns defaults if cookie is missing or malformed.
|
||||
*/
|
||||
export async function getCodeThemePrefs(): Promise<CodeThemePreferences> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(CODE_THEME_COOKIE)?.value;
|
||||
if (!raw) return DEFAULT_CODE_THEME_PREFS;
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
codeThemeLight: parsed.light ?? DEFAULT_CODE_THEME_PREFS.codeThemeLight,
|
||||
codeThemeDark: parsed.dark ?? DEFAULT_CODE_THEME_PREFS.codeThemeDark,
|
||||
codeFont: parsed.font ?? DEFAULT_CODE_THEME_PREFS.codeFont,
|
||||
codeFontSize: parsed.fontSize ?? DEFAULT_CODE_THEME_PREFS.codeFontSize,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_CODE_THEME_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side: set the code theme prefs cookie.
|
||||
*/
|
||||
export function setCodeThemeCookie(prefs: CodeThemePreferences): void {
|
||||
const value = JSON.stringify({
|
||||
light: prefs.codeThemeLight,
|
||||
dark: prefs.codeThemeDark,
|
||||
font: prefs.codeFont,
|
||||
fontSize: prefs.codeFontSize,
|
||||
});
|
||||
document.cookie = `${CODE_THEME_COOKIE}=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
export interface CodeFontOption {
|
||||
id: string;
|
||||
name: string;
|
||||
family: string;
|
||||
googleFontsUrl?: string;
|
||||
}
|
||||
|
||||
export const CODE_FONTS: CodeFontOption[] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "JetBrains Mono",
|
||||
family: "var(--font-code), ui-monospace, monospace",
|
||||
},
|
||||
{
|
||||
id: "geist-mono",
|
||||
name: "Geist Mono",
|
||||
family: "var(--font-geist-mono), ui-monospace, monospace",
|
||||
},
|
||||
{
|
||||
id: "fira-code",
|
||||
name: "Fira Code",
|
||||
family: "'Fira Code', ui-monospace, monospace",
|
||||
googleFontsUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap",
|
||||
},
|
||||
{
|
||||
id: "source-code-pro",
|
||||
name: "Source Code Pro",
|
||||
family: "'Source Code Pro', ui-monospace, monospace",
|
||||
googleFontsUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600&display=swap",
|
||||
},
|
||||
{
|
||||
id: "ibm-plex-mono",
|
||||
name: "IBM Plex Mono",
|
||||
family: "'IBM Plex Mono', ui-monospace, monospace",
|
||||
googleFontsUrl:
|
||||
"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
{
|
||||
id: "sf-mono",
|
||||
name: "SF Mono (System)",
|
||||
family: "'SF Mono', 'Menlo', 'Monaco', 'Consolas', ui-monospace, monospace",
|
||||
},
|
||||
];
|
||||
|
||||
export function getCodeFont(id: string): CodeFontOption | undefined {
|
||||
return CODE_FONTS.find((f) => f.id === id);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
export type { CodeThemeOption, CustomCodeTheme, CodeThemePreferences } from "./types";
|
||||
export {
|
||||
DEFAULT_CODE_THEME_LIGHT,
|
||||
DEFAULT_CODE_THEME_DARK,
|
||||
DEFAULT_CODE_FONT,
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
DEFAULT_CODE_THEME_PREFS,
|
||||
} from "./types";
|
||||
|
||||
export { BUILT_IN_THEMES, getBuiltInTheme, getBuiltInThemesByMode } from "./built-in";
|
||||
export { CODE_FONTS, getCodeFont } from "./fonts";
|
||||
export type { CodeFontOption } from "./fonts";
|
||||
|
||||
export {
|
||||
getCustomThemes,
|
||||
getCustomTheme,
|
||||
saveCustomTheme,
|
||||
deleteCustomTheme,
|
||||
extractColorsFromVSCodeTheme,
|
||||
} from "./store";
|
||||
|
||||
export { CODE_THEME_COOKIE, getCodeThemePrefs, setCodeThemeCookie } from "./cookie";
|
||||
|
||||
import { BUILT_IN_THEMES } from "./built-in";
|
||||
import { getCustomThemes } from "./store";
|
||||
import type { CodeThemeOption } from "./types";
|
||||
|
||||
/**
|
||||
* Get all available themes (built-in + user's custom), optionally filtered by mode.
|
||||
*/
|
||||
export async function getAvailableThemes(
|
||||
userId: string,
|
||||
mode?: "dark" | "light",
|
||||
): Promise<CodeThemeOption[]> {
|
||||
const custom = (await getCustomThemes(userId)).map(
|
||||
(ct): CodeThemeOption => ({
|
||||
id: ct.id,
|
||||
name: ct.name,
|
||||
type: "custom",
|
||||
mode: ct.mode,
|
||||
bgColor: ct.bgColor,
|
||||
fgColor: ct.fgColor,
|
||||
accentColor: ct.accentColor,
|
||||
}),
|
||||
);
|
||||
|
||||
const all = [...BUILT_IN_THEMES, ...custom];
|
||||
return mode ? all.filter((t) => t.mode === mode) : all;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { prisma } from "../db";
|
||||
import type { CustomCodeTheme } from "./types";
|
||||
|
||||
function toTheme(row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
mode: string;
|
||||
themeJson: string;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
accentColor: string;
|
||||
createdAt: string;
|
||||
}): CustomCodeTheme {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
mode: row.mode as "dark" | "light",
|
||||
themeJson: row.themeJson,
|
||||
bgColor: row.bgColor,
|
||||
fgColor: row.fgColor,
|
||||
accentColor: row.accentColor,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCustomThemes(userId: string): Promise<CustomCodeTheme[]> {
|
||||
const rows = await prisma.customCodeTheme.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map(toTheme);
|
||||
}
|
||||
|
||||
export async function getCustomTheme(id: string): Promise<CustomCodeTheme | null> {
|
||||
const row = await prisma.customCodeTheme.findUnique({ where: { id } });
|
||||
return row ? toTheme(row) : null;
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(
|
||||
userId: string,
|
||||
name: string,
|
||||
mode: "dark" | "light",
|
||||
themeJson: string,
|
||||
bgColor: string,
|
||||
fgColor: string,
|
||||
accentColor: string,
|
||||
): Promise<CustomCodeTheme> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const created = await prisma.customCodeTheme.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
mode,
|
||||
themeJson,
|
||||
bgColor,
|
||||
fgColor,
|
||||
accentColor,
|
||||
createdAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return toTheme(created);
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(id: string, userId: string): Promise<boolean> {
|
||||
const result = await prisma.customCodeTheme.deleteMany({
|
||||
where: { id, userId },
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract editor colors from a VS Code theme JSON.
|
||||
* Returns { bg, fg, accent } from editor colors and tokenColors.
|
||||
*/
|
||||
export function extractColorsFromVSCodeTheme(json: Record<string, unknown>): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
accent: string;
|
||||
mode: "dark" | "light";
|
||||
} {
|
||||
const colors = (json.colors ?? {}) as Record<string, string>;
|
||||
const bg = colors["editor.background"] ?? "#1e1e1e";
|
||||
const fg = colors["editor.foreground"] ?? "#d4d4d4";
|
||||
|
||||
let accent = fg;
|
||||
const tokenColors = json.tokenColors as
|
||||
| Array<{
|
||||
scope?: string | string[];
|
||||
settings?: { foreground?: string };
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
if (Array.isArray(tokenColors)) {
|
||||
for (const tc of tokenColors) {
|
||||
const scopes = Array.isArray(tc.scope) ? tc.scope : [tc.scope];
|
||||
if (scopes.some((s) => s === "keyword" || s === "keyword.control")) {
|
||||
accent = tc.settings?.foreground ?? accent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mode = isColorDark(bg) ? "dark" : "light";
|
||||
|
||||
return { bg, fg, accent, mode };
|
||||
}
|
||||
|
||||
function isColorDark(hex: string): boolean {
|
||||
const c = hex.replace("#", "");
|
||||
if (c.length < 6) return true;
|
||||
const r = parseInt(c.slice(0, 2), 16);
|
||||
const g = parseInt(c.slice(2, 4), 16);
|
||||
const b = parseInt(c.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance < 0.5;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface CodeThemeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "built-in" | "custom";
|
||||
mode: "dark" | "light";
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export interface CustomCodeTheme {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
mode: "dark" | "light";
|
||||
themeJson: string;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
accentColor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CodeThemePreferences {
|
||||
codeThemeLight: string;
|
||||
codeThemeDark: string;
|
||||
codeFont: string;
|
||||
codeFontSize: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CODE_THEME_LIGHT = "vitesse-light";
|
||||
export const DEFAULT_CODE_THEME_DARK = "vitesse-black";
|
||||
export const DEFAULT_CODE_FONT = "default";
|
||||
export const DEFAULT_CODE_FONT_SIZE = 13;
|
||||
|
||||
export const DEFAULT_CODE_THEME_PREFS: CodeThemePreferences = {
|
||||
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
|
||||
codeThemeDark: DEFAULT_CODE_THEME_DARK,
|
||||
codeFont: DEFAULT_CODE_FONT,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
};
|
||||
@@ -133,8 +133,6 @@ export type PinRemovedEvent = { type: "pin:removed"; owner: string; repo: string
|
||||
// ── Settings Events ───────────────────────────────────────────
|
||||
|
||||
export type SettingsUpdatedEvent = { type: "settings:updated" };
|
||||
export type CodeThemeCreatedEvent = { type: "code-theme:created" };
|
||||
export type CodeThemeDeletedEvent = { type: "code-theme:deleted" };
|
||||
export type GitHubAccountAddedEvent = { type: "github-account:added" };
|
||||
export type GitHubAccountRemovedEvent = { type: "github-account:removed" };
|
||||
export type GitHubAccountSwitchedEvent = { type: "github-account:switched" };
|
||||
@@ -175,8 +173,6 @@ export type MutationEvent =
|
||||
| PinAddedEvent
|
||||
| PinRemovedEvent
|
||||
| SettingsUpdatedEvent
|
||||
| CodeThemeCreatedEvent
|
||||
| CodeThemeDeletedEvent
|
||||
| GitHubAccountAddedEvent
|
||||
| GitHubAccountRemovedEvent
|
||||
| GitHubAccountSwitchedEvent;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Highlighter, BundledLanguage } from "shiki";
|
||||
import { getTheme } from "./themes";
|
||||
import type { ShikiTheme } from "./themes/types";
|
||||
|
||||
const DEFAULT_LIGHT_THEME = "vitesse-light";
|
||||
const DEFAULT_DARK_THEME = "vitesse-black";
|
||||
const MAX_TOKENIZE_LENGTH = 200_000;
|
||||
|
||||
let highlighterInstance: Highlighter | null = null;
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getClientHighlighter(): Promise<Highlighter> {
|
||||
if (highlighterInstance) return Promise.resolve(highlighterInstance);
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = import("shiki")
|
||||
.then(({ createHighlighter }) =>
|
||||
createHighlighter({
|
||||
themes: [DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME],
|
||||
langs: [],
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
highlighterInstance = h;
|
||||
return h;
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadCustomSyntaxTheme(
|
||||
highlighter: Highlighter,
|
||||
theme: ShikiTheme,
|
||||
uniqueName: string,
|
||||
): Promise<string> {
|
||||
const loaded = highlighter.getLoadedThemes();
|
||||
if (loaded.includes(uniqueName)) return uniqueName;
|
||||
|
||||
try {
|
||||
const themeWithName = { ...theme, name: uniqueName };
|
||||
await highlighter.loadTheme(themeWithName);
|
||||
return uniqueName;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThemeWithCustomBg(
|
||||
highlighter: Highlighter,
|
||||
baseThemeId: string,
|
||||
bgColor: string,
|
||||
uniqueName: string,
|
||||
): Promise<string> {
|
||||
const loaded = highlighter.getLoadedThemes();
|
||||
if (loaded.includes(uniqueName)) return uniqueName;
|
||||
|
||||
try {
|
||||
if (!loaded.includes(baseThemeId)) {
|
||||
await highlighter.loadTheme(
|
||||
baseThemeId as Parameters<Highlighter["loadTheme"]>[0],
|
||||
);
|
||||
}
|
||||
const baseTheme = highlighter.getTheme(baseThemeId);
|
||||
const modifiedTheme = {
|
||||
...baseTheme,
|
||||
name: uniqueName,
|
||||
colors: {
|
||||
...baseTheme.colors,
|
||||
"editor.background": bgColor,
|
||||
},
|
||||
};
|
||||
await highlighter.loadTheme(modifiedTheme);
|
||||
return uniqueName;
|
||||
} catch {
|
||||
return baseThemeId;
|
||||
}
|
||||
}
|
||||
|
||||
async function getThemePairForClient(
|
||||
highlighter: Highlighter,
|
||||
themeId: string,
|
||||
): Promise<{ light: string; dark: string }> {
|
||||
const appTheme = getTheme(themeId);
|
||||
let light = DEFAULT_LIGHT_THEME;
|
||||
let dark = DEFAULT_DARK_THEME;
|
||||
|
||||
if (appTheme) {
|
||||
if (appTheme.light.syntax) {
|
||||
const customLightName = `${appTheme.id}-syntax-light`;
|
||||
const loadedName = await loadCustomSyntaxTheme(
|
||||
highlighter,
|
||||
appTheme.light.syntax,
|
||||
customLightName,
|
||||
);
|
||||
if (loadedName) light = loadedName;
|
||||
} else {
|
||||
const codeBg = appTheme.light.colors["--code-bg"];
|
||||
if (codeBg) {
|
||||
const customName = `${appTheme.id}-light-vitesse`;
|
||||
light = await loadThemeWithCustomBg(
|
||||
highlighter,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
codeBg,
|
||||
customName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (appTheme.dark.syntax) {
|
||||
const customDarkName = `${appTheme.id}-syntax-dark`;
|
||||
const loadedName = await loadCustomSyntaxTheme(
|
||||
highlighter,
|
||||
appTheme.dark.syntax,
|
||||
customDarkName,
|
||||
);
|
||||
if (loadedName) dark = loadedName;
|
||||
} else {
|
||||
const codeBg = appTheme.dark.colors["--code-bg"];
|
||||
if (codeBg) {
|
||||
const customName = `${appTheme.id}-dark-vitesse`;
|
||||
dark = await loadThemeWithCustomBg(
|
||||
highlighter,
|
||||
DEFAULT_DARK_THEME,
|
||||
codeBg,
|
||||
customName,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
export async function highlightCodeClient(
|
||||
code: string,
|
||||
lang: string,
|
||||
themeId: string,
|
||||
): Promise<string> {
|
||||
if (code.length > MAX_TOKENIZE_LENGTH) {
|
||||
return `<pre><code>${escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
|
||||
const highlighter = await getClientHighlighter();
|
||||
const themes = await getThemePairForClient(highlighter, themeId);
|
||||
|
||||
const loaded = highlighter.getLoadedLanguages();
|
||||
let effectiveLang = lang || "text";
|
||||
|
||||
if (!loaded.includes(effectiveLang)) {
|
||||
try {
|
||||
await highlighter.loadLanguage(effectiveLang as BundledLanguage);
|
||||
} catch {
|
||||
effectiveLang = "text";
|
||||
if (!loaded.includes("text")) {
|
||||
try {
|
||||
await highlighter.loadLanguage("text" as BundledLanguage);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: effectiveLang,
|
||||
themes: { light: themes.light, dark: themes.dark },
|
||||
defaultColor: false,
|
||||
});
|
||||
} catch {
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: "text",
|
||||
themes: { light: themes.light, dark: themes.dark },
|
||||
defaultColor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
+142
-52
@@ -5,11 +5,13 @@ import {
|
||||
type BundledTheme,
|
||||
} from "shiki";
|
||||
import { parseDiffPatch, getLanguageFromFilename } from "./github-utils";
|
||||
import { getBuiltInTheme } from "./code-themes/built-in";
|
||||
import { DEFAULT_CODE_THEME_LIGHT, DEFAULT_CODE_THEME_DARK } from "./code-themes/types";
|
||||
import { getTheme, getThemeVariant } from "./themes";
|
||||
import type { ShikiTheme, ThemeVariant } from "./themes/types";
|
||||
|
||||
const FALLBACK_THEMES = [DEFAULT_CODE_THEME_LIGHT, DEFAULT_CODE_THEME_DARK] as const;
|
||||
const FALLBACK_PAIR = { light: DEFAULT_CODE_THEME_LIGHT, dark: DEFAULT_CODE_THEME_DARK };
|
||||
const DEFAULT_LIGHT_THEME = "vitesse-light";
|
||||
const DEFAULT_DARK_THEME = "vitesse-black";
|
||||
const FALLBACK_THEMES = [DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME] as const;
|
||||
const FALLBACK_PAIR = { light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME };
|
||||
const MAX_TOKENIZE_LENGTH = 200_000; // Skip tokenization for very large inputs to avoid WASM OOM
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
@@ -34,69 +36,107 @@ function getHighlighter(): Promise<Highlighter> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read code-theme-prefs cookie from the raw Cookie header.
|
||||
* Read theme preferences from cookies.
|
||||
* Uses dynamic import of next/headers to avoid issues in non-request contexts.
|
||||
* Returns defaults if anything goes wrong.
|
||||
*/
|
||||
async function readThemePrefsFromCookie(): Promise<{ light: string; dark: string }> {
|
||||
async function readThemePrefsFromCookie(): Promise<{
|
||||
themeId: string | null;
|
||||
mode: "dark" | "light";
|
||||
}> {
|
||||
try {
|
||||
// Dynamic import + 200ms timeout to guard against hanging cookies()/headers()
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get("code-theme-prefs")?.value;
|
||||
if (!raw) return FALLBACK_PAIR;
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
light: (parsed.light as string) || FALLBACK_PAIR.light,
|
||||
dark: (parsed.dark as string) || FALLBACK_PAIR.dark,
|
||||
};
|
||||
const themeId = cookieStore.get("color-theme")?.value ?? null;
|
||||
const mode =
|
||||
(cookieStore.get("color-mode")?.value as
|
||||
| "dark"
|
||||
| "light") ?? "dark";
|
||||
return { themeId, mode };
|
||||
})(),
|
||||
new Promise<{ light: string; dark: string }>((resolve) =>
|
||||
setTimeout(() => resolve(FALLBACK_PAIR), 200),
|
||||
new Promise<{ themeId: string | null; mode: "dark" | "light" }>((resolve) =>
|
||||
setTimeout(() => resolve({ themeId: null, mode: "dark" }), 200),
|
||||
),
|
||||
]);
|
||||
return result;
|
||||
} catch {
|
||||
return FALLBACK_PAIR;
|
||||
return { themeId: null, mode: "dark" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a theme is loaded in the highlighter.
|
||||
* Handles built-in Shiki themes by name and custom themes from DB JSON.
|
||||
* Ensure a built-in Shiki theme is loaded.
|
||||
*/
|
||||
async function ensureThemeLoaded(highlighter: Highlighter, themeId: string): Promise<string> {
|
||||
async function ensureBuiltInThemeLoaded(
|
||||
highlighter: Highlighter,
|
||||
themeId: string,
|
||||
): Promise<string> {
|
||||
const loaded = highlighter.getLoadedThemes();
|
||||
if (loaded.includes(themeId)) return themeId;
|
||||
|
||||
// Check if it's a built-in Shiki theme
|
||||
const builtIn = getBuiltInTheme(themeId);
|
||||
if (builtIn) {
|
||||
try {
|
||||
await highlighter.loadTheme(themeId as BundledTheme);
|
||||
return themeId;
|
||||
} catch {
|
||||
// Fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a custom theme from DB (lazy import to avoid bundling issues)
|
||||
try {
|
||||
const { getCustomTheme } = await import("./code-themes/store");
|
||||
const custom = await getCustomTheme(themeId);
|
||||
if (custom) {
|
||||
const themeJson = JSON.parse(custom.themeJson);
|
||||
themeJson.name = themeId;
|
||||
await highlighter.loadTheme(themeJson);
|
||||
return themeId;
|
||||
}
|
||||
await highlighter.loadTheme(themeId as BundledTheme);
|
||||
return themeId;
|
||||
} catch {
|
||||
// Fall through to fallback
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
/**
|
||||
* Load a built-in theme with a custom background color.
|
||||
* Returns a unique theme name that can be used for highlighting.
|
||||
*/
|
||||
async function loadThemeWithCustomBg(
|
||||
highlighter: Highlighter,
|
||||
baseThemeId: string,
|
||||
bgColor: string,
|
||||
uniqueName: string,
|
||||
): Promise<string> {
|
||||
const loaded = highlighter.getLoadedThemes();
|
||||
if (loaded.includes(uniqueName)) return uniqueName;
|
||||
|
||||
try {
|
||||
// Ensure base theme is loaded first
|
||||
await ensureBuiltInThemeLoaded(highlighter, baseThemeId);
|
||||
|
||||
// Get the theme object and modify its background
|
||||
const baseTheme = highlighter.getTheme(baseThemeId);
|
||||
const modifiedTheme = {
|
||||
...baseTheme,
|
||||
name: uniqueName,
|
||||
colors: {
|
||||
...baseTheme.colors,
|
||||
"editor.background": bgColor,
|
||||
},
|
||||
};
|
||||
|
||||
await highlighter.loadTheme(modifiedTheme);
|
||||
return uniqueName;
|
||||
} catch {
|
||||
// Fall back to the original theme
|
||||
return baseThemeId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a custom ShikiTheme object into the highlighter.
|
||||
*/
|
||||
async function loadCustomSyntaxTheme(
|
||||
highlighter: Highlighter,
|
||||
theme: ShikiTheme,
|
||||
uniqueName: string,
|
||||
): Promise<string> {
|
||||
const loaded = highlighter.getLoadedThemes();
|
||||
if (loaded.includes(uniqueName)) return uniqueName;
|
||||
|
||||
try {
|
||||
const themeWithName = { ...theme, name: uniqueName };
|
||||
await highlighter.loadTheme(themeWithName);
|
||||
return uniqueName;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Cache theme pair per-request to avoid redundant cookie reads + theme loads
|
||||
@@ -120,17 +160,67 @@ async function resolveThemePair(
|
||||
highlighter: Highlighter,
|
||||
): Promise<{ light: string; dark: string }> {
|
||||
try {
|
||||
const prefs = await readThemePrefsFromCookie();
|
||||
const { themeId } = await readThemePrefsFromCookie();
|
||||
const appTheme = themeId ? getTheme(themeId) : null;
|
||||
|
||||
const light =
|
||||
(await ensureThemeLoaded(highlighter, prefs.light)) ||
|
||||
(await ensureThemeLoaded(highlighter, DEFAULT_CODE_THEME_LIGHT)) ||
|
||||
DEFAULT_CODE_THEME_LIGHT;
|
||||
let light = DEFAULT_LIGHT_THEME;
|
||||
let dark = DEFAULT_DARK_THEME;
|
||||
|
||||
const dark =
|
||||
(await ensureThemeLoaded(highlighter, prefs.dark)) ||
|
||||
(await ensureThemeLoaded(highlighter, DEFAULT_CODE_THEME_DARK)) ||
|
||||
DEFAULT_CODE_THEME_DARK;
|
||||
if (appTheme) {
|
||||
// Load light variant's syntax theme
|
||||
if (appTheme.light.syntax) {
|
||||
const customLightName = `${appTheme.id}-syntax-light`;
|
||||
const loadedName = await loadCustomSyntaxTheme(
|
||||
highlighter,
|
||||
appTheme.light.syntax,
|
||||
customLightName,
|
||||
);
|
||||
if (loadedName) light = loadedName;
|
||||
} else {
|
||||
// No custom syntax - use vitesse but with the theme's code-bg
|
||||
const codeBg = appTheme.light.colors["--code-bg"];
|
||||
if (codeBg) {
|
||||
const customName = `${appTheme.id}-light-vitesse`;
|
||||
light = await loadThemeWithCustomBg(
|
||||
highlighter,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
codeBg,
|
||||
customName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load dark variant's syntax theme
|
||||
if (appTheme.dark.syntax) {
|
||||
const customDarkName = `${appTheme.id}-syntax-dark`;
|
||||
const loadedName = await loadCustomSyntaxTheme(
|
||||
highlighter,
|
||||
appTheme.dark.syntax,
|
||||
customDarkName,
|
||||
);
|
||||
if (loadedName) dark = loadedName;
|
||||
} else {
|
||||
// No custom syntax - use vitesse but with the theme's code-bg
|
||||
const codeBg = appTheme.dark.colors["--code-bg"];
|
||||
if (codeBg) {
|
||||
const customName = `${appTheme.id}-dark-vitesse`;
|
||||
dark = await loadThemeWithCustomBg(
|
||||
highlighter,
|
||||
DEFAULT_DARK_THEME,
|
||||
codeBg,
|
||||
customName,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure fallback themes are loaded
|
||||
if (light === DEFAULT_LIGHT_THEME) {
|
||||
await ensureBuiltInThemeLoaded(highlighter, DEFAULT_LIGHT_THEME);
|
||||
}
|
||||
if (dark === DEFAULT_DARK_THEME) {
|
||||
await ensureBuiltInThemeLoaded(highlighter, DEFAULT_DARK_THEME);
|
||||
}
|
||||
|
||||
return { light, dark };
|
||||
} catch {
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import type { ThemeDefinition } from "./themes/types";
|
||||
import type { ThemeDefinition, ThemeVariant } from "./themes/types";
|
||||
|
||||
interface ThemeScriptData {
|
||||
dark: { colors: Record<string, string> };
|
||||
light: { colors: Record<string, string> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an inline script that applies the saved color theme before first paint.
|
||||
* If no theme is saved, detects system preference (dark/light) and picks the
|
||||
* user's preferred theme for that mode. Sets both CSS variables and the dark/light class.
|
||||
* Reads theme ID and mode from localStorage, then applies the correct variant's colors.
|
||||
*/
|
||||
export function generateThemeScript(themes: ThemeDefinition[]): string {
|
||||
// Serialize: id → { mode, colors }
|
||||
const data: Record<string, { mode: string; colors: Record<string, string> }> = {};
|
||||
const data: Record<string, ThemeScriptData> = {};
|
||||
for (const t of themes) {
|
||||
data[t.id] = { mode: t.mode, colors: { ...t.colors } };
|
||||
data[t.id] = {
|
||||
dark: { colors: { ...t.dark.colors } },
|
||||
light: { colors: { ...t.light.colors } },
|
||||
};
|
||||
}
|
||||
|
||||
return `(function(){try{var d=document.documentElement;var id=localStorage.getItem("color-theme");if(!id){var prefersDark=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;var dk=localStorage.getItem("dark-theme")||"midnight";var lt=localStorage.getItem("light-theme")||"hub-light";id=prefersDark?dk:lt;localStorage.setItem("color-theme",id)}var themes=${JSON.stringify(data)};var t=themes[id];if(!t)t=themes["midnight"];if(!t)return;if(t.mode==="dark"){d.classList.add("dark");d.classList.remove("light");d.style.colorScheme="dark"}else{d.classList.remove("dark");d.classList.add("light");d.style.colorScheme="light"}localStorage.setItem("theme",t.mode);if(id!=="midnight"){for(var k in t.colors){d.style.setProperty(k,t.colors[k])}}var cp=localStorage.getItem("code-theme-prefs");if(cp){try{var p=JSON.parse(cp);if(p.bg)d.style.setProperty("--code-theme-bg",p.bg);if(p.font)d.style.setProperty("--code-font-override",p.font);if(p.fontSize)d.style.setProperty("--code-font-size",p.fontSize+"px")}catch(e2){}}}catch(e){}})()`;
|
||||
const legacyMap: Record<string, { themeId: string; mode: string }> = {
|
||||
midnight: { themeId: "hub", mode: "dark" },
|
||||
"hub-light": { themeId: "hub", mode: "light" },
|
||||
"hub-dark": { themeId: "zinc", mode: "dark" },
|
||||
dawn: { themeId: "ember", mode: "light" },
|
||||
};
|
||||
|
||||
return `(function(){try{var d=document.documentElement;var themes=${JSON.stringify(data)};var legacy=${JSON.stringify(legacyMap)};var id=localStorage.getItem("color-theme");var mode=localStorage.getItem("color-mode");if(id&&legacy[id]){var m=legacy[id];id=m.themeId;mode=m.mode;localStorage.setItem("color-theme",id);localStorage.setItem("color-mode",mode)}if(!id)id="hub";if(!mode){var prefersDark=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;mode=prefersDark?"dark":"light";localStorage.setItem("color-mode",mode)}var t=themes[id];if(!t)t=themes["hub"];if(!t)return;var v=t[mode];if(!v)v=t.dark;if(mode==="dark"){d.classList.add("dark");d.classList.remove("light");d.style.colorScheme="dark"}else{d.classList.remove("dark");d.classList.add("light");d.style.colorScheme="light"}localStorage.setItem("theme",mode);if(!(id==="hub"&&mode==="dark")){for(var k in v.colors){d.style.setProperty(k,v.colors[k])}}}catch(e){}})()`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import type { ThemeColors, ThemeDefinition } from "./types";
|
||||
import { midnight, hubLight, hubDark, ember, arctic, nordWave, dawn } from "./themes";
|
||||
import type { ThemeColors, ThemeDefinition, ThemeVariant, ShikiTheme } from "./types";
|
||||
import {
|
||||
arctic,
|
||||
cloudflare,
|
||||
ember,
|
||||
forest,
|
||||
gemini,
|
||||
hub,
|
||||
mintlify,
|
||||
noir,
|
||||
nordWave,
|
||||
openai,
|
||||
rabbit,
|
||||
stripe,
|
||||
supabase,
|
||||
tailwind,
|
||||
vercel,
|
||||
zinc,
|
||||
LEGACY_THEME_MAP,
|
||||
} from "./themes";
|
||||
|
||||
export type { ThemeColors, ThemeDefinition };
|
||||
const themes: ThemeDefinition[] = [
|
||||
hub,
|
||||
ember,
|
||||
zinc,
|
||||
arctic,
|
||||
nordWave,
|
||||
vercel,
|
||||
rabbit,
|
||||
supabase,
|
||||
tailwind,
|
||||
openai,
|
||||
mintlify,
|
||||
cloudflare,
|
||||
gemini,
|
||||
stripe,
|
||||
noir,
|
||||
forest,
|
||||
];
|
||||
|
||||
export type { ThemeColors, ThemeDefinition, ThemeVariant, ShikiTheme };
|
||||
|
||||
export const STORAGE_KEY = "color-theme";
|
||||
export const DARK_THEME_KEY = "dark-theme";
|
||||
export const LIGHT_THEME_KEY = "light-theme";
|
||||
export const DEFAULT_THEME_ID = "midnight";
|
||||
export const DARK_THEME_ID = "midnight";
|
||||
export const LIGHT_THEME_ID = "hub-light";
|
||||
|
||||
const themes: ThemeDefinition[] = [midnight, hubDark, hubLight, ember, arctic, nordWave, dawn];
|
||||
export const MODE_KEY = "color-mode";
|
||||
export const DEFAULT_THEME_ID = "hub";
|
||||
export const DEFAULT_MODE: "dark" | "light" = "dark";
|
||||
|
||||
const themeMap = new Map(themes.map((t) => [t.id, t]));
|
||||
|
||||
@@ -18,35 +51,33 @@ export function listThemes(): ThemeDefinition[] {
|
||||
return themes;
|
||||
}
|
||||
|
||||
export function listDarkThemes(): ThemeDefinition[] {
|
||||
return themes.filter((t) => t.mode === "dark");
|
||||
}
|
||||
|
||||
export function listLightThemes(): ThemeDefinition[] {
|
||||
return themes.filter((t) => t.mode === "light");
|
||||
}
|
||||
|
||||
export function getTheme(id: string): ThemeDefinition | undefined {
|
||||
return themeMap.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a theme by setting CSS custom properties on documentElement.
|
||||
* Also syncs the dark/light class to match the theme's mode.
|
||||
*/
|
||||
export function applyTheme(themeId: string): void {
|
||||
export function getThemeVariant(id: string, mode: "dark" | "light"): ThemeVariant | undefined {
|
||||
const theme = themeMap.get(id);
|
||||
return theme?.[mode];
|
||||
}
|
||||
|
||||
export function migrateLegacyThemeId(
|
||||
legacyId: string,
|
||||
): { themeId: string; mode: "dark" | "light" } | undefined {
|
||||
return LEGACY_THEME_MAP[legacyId];
|
||||
}
|
||||
|
||||
export function applyTheme(themeId: string, mode: "dark" | "light"): void {
|
||||
const el = document.documentElement;
|
||||
const theme = getTheme(themeId);
|
||||
const variant = theme?.[mode];
|
||||
|
||||
// Get all CSS var keys from the midnight theme as reference
|
||||
const allKeys = Object.keys(midnight.colors) as (keyof ThemeColors)[];
|
||||
const hubDark = hub.dark;
|
||||
const allKeys = Object.keys(hubDark.colors) as (keyof ThemeColors)[];
|
||||
|
||||
if (!theme || themeId === DEFAULT_THEME_ID) {
|
||||
// Remove all inline overrides — let globals.css take over
|
||||
if (!variant || (themeId === DEFAULT_THEME_ID && mode === DEFAULT_MODE)) {
|
||||
for (const key of allKeys) {
|
||||
el.style.removeProperty(key);
|
||||
}
|
||||
// Still sync the dark/light class for midnight
|
||||
el.classList.add("dark");
|
||||
el.classList.remove("light");
|
||||
el.style.colorScheme = "dark";
|
||||
@@ -54,11 +85,10 @@ export function applyTheme(themeId: string): void {
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
el.style.setProperty(key, theme.colors[key]);
|
||||
el.style.setProperty(key, variant.colors[key]);
|
||||
}
|
||||
|
||||
// Sync the dark/light class immediately alongside CSS vars
|
||||
if (theme.mode === "dark") {
|
||||
if (mode === "dark") {
|
||||
el.classList.add("dark");
|
||||
el.classList.remove("light");
|
||||
el.style.colorScheme = "dark";
|
||||
|
||||
+5883
-435
File diff suppressed because it is too large
Load Diff
@@ -57,14 +57,51 @@ export interface ThemeColors {
|
||||
"--contrib-4": string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
export interface ShikiThemeInput {
|
||||
foreground: string;
|
||||
constant?: string;
|
||||
string?: string;
|
||||
comment?: string;
|
||||
keyword?: string;
|
||||
parameter?: string;
|
||||
function?: string;
|
||||
stringExpression?: string;
|
||||
punctuation?: string;
|
||||
link?: string;
|
||||
number?: string;
|
||||
property?: string;
|
||||
objectLiteral?: string;
|
||||
highlight?: string;
|
||||
highlightHover?: string;
|
||||
highlightBorder?: string;
|
||||
diffDeleted?: string;
|
||||
diffInserted?: string;
|
||||
}
|
||||
|
||||
export interface ShikiTheme {
|
||||
name: string;
|
||||
description: string;
|
||||
mode: "dark" | "light";
|
||||
type: "light" | "dark";
|
||||
colors: Record<string, string>;
|
||||
tokenColors: Array<{
|
||||
scope: string | string[];
|
||||
settings: { foreground?: string; fontStyle?: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ThemeVariant {
|
||||
/** Accent color for preview dot */
|
||||
accentPreview: string;
|
||||
/** Background color for preview dot */
|
||||
bgPreview: string;
|
||||
colors: ThemeColors;
|
||||
/** Optional syntax highlighting theme for code blocks (matches variant mode) */
|
||||
syntax?: ShikiTheme;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dark: ThemeVariant;
|
||||
light: ThemeVariant;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface UserSettings {
|
||||
displayName: string | null;
|
||||
theme: string;
|
||||
colorTheme: string;
|
||||
colorMode: string;
|
||||
ghostModel: string;
|
||||
useOwnApiKey: boolean;
|
||||
openrouterApiKey: string | null;
|
||||
@@ -22,6 +23,7 @@ function toSettings(row: {
|
||||
displayName: string | null;
|
||||
theme: string;
|
||||
colorTheme: string;
|
||||
colorMode?: string;
|
||||
ghostModel: string;
|
||||
useOwnApiKey: boolean;
|
||||
openrouterApiKey: string | null;
|
||||
@@ -38,6 +40,7 @@ function toSettings(row: {
|
||||
displayName: row.displayName,
|
||||
theme: row.theme,
|
||||
colorTheme: row.colorTheme,
|
||||
colorMode: row.colorMode ?? "dark",
|
||||
ghostModel: row.ghostModel,
|
||||
useOwnApiKey: row.useOwnApiKey,
|
||||
openrouterApiKey: row.openrouterApiKey,
|
||||
@@ -76,6 +79,7 @@ export async function updateUserSettings(
|
||||
| "displayName"
|
||||
| "theme"
|
||||
| "colorTheme"
|
||||
| "colorMode"
|
||||
| "ghostModel"
|
||||
| "useOwnApiKey"
|
||||
| "openrouterApiKey"
|
||||
@@ -101,6 +105,7 @@ export async function updateUserSettings(
|
||||
if (updates.displayName !== undefined) data.displayName = updates.displayName;
|
||||
if (updates.theme !== undefined) data.theme = updates.theme;
|
||||
if (updates.colorTheme !== undefined) data.colorTheme = updates.colorTheme;
|
||||
if (updates.colorMode !== undefined) data.colorMode = updates.colorMode;
|
||||
if (updates.ghostModel !== undefined) data.ghostModel = updates.ghostModel;
|
||||
if (updates.useOwnApiKey !== undefined) data.useOwnApiKey = updates.useOwnApiKey;
|
||||
if (updates.openrouterApiKey !== undefined)
|
||||
|
||||
Reference in New Issue
Block a user