diff --git a/apps/status-page/src/app/(public)/client.tsx b/apps/status-page/src/app/(public)/client.tsx index 6e80a01e..1676aa5f 100644 --- a/apps/status-page/src/app/(public)/client.tsx +++ b/apps/status-page/src/app/(public)/client.tsx @@ -1,5 +1,6 @@ "use client"; +import { Sidebar as SidebarIcon } from "@openstatus/icons"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { StatusBanner } from "@openstatus/ui/components/blocks/status-banner"; import { @@ -37,10 +38,18 @@ import { } from "../../components/content/section"; import { recomputeStyles } from "../../components/status-page/floating-button"; import { StatusComponentStatic } from "../../components/status-page/static/status-component-static"; +import { ThemePromptButton } from "../../components/themes/theme-agent-actions"; +import { useThemeBuilder } from "../../components/themes/theme-builder-provider"; import { ThemePalettePicker } from "../../components/themes/theme-palette-picker"; +import { ThemePasteDialog } from "../../components/themes/theme-paste-dialog"; import { ThemeSelect } from "../../components/themes/theme-select"; import { monitors } from "../../data/monitors"; import { defaultLocale } from "../../i18n/config"; +import { clearThemeDraft, saveThemeDraft } from "../../lib/theme-draft"; +import { + THEME_SKILL_INSTALL_COMMAND, + THEME_SKILL_URL, +} from "../../lib/theme-prompt"; import { useTRPC } from "../../lib/trpc/client"; import { searchParamsParsers } from "./search-params"; @@ -60,6 +69,7 @@ export function Client() { const [{ q, t }, setSearchParams] = useQueryStates(searchParamsParsers); const theme = t ? THEMES[t as keyof typeof THEMES] : undefined; const { toggleSidebar } = useSidebar(); + const builder = useThemeBuilder(); useEffect(() => { setIsMounted(true); @@ -218,42 +228,63 @@ export function Client() {

- You can contribute your own theme by creating a new file in the{" "} - @openstatus/theme-store package. You'll only need - to override css variables. If you are familiar with shadcn, you'll - know the trick (it also allows you to override `--radius`). Make - sure your object is satisfying the Theme interface. We - provide a theme builder to help you with the process. -

- -

- Go to the{" "} - - GitHub directory - {" "} - to see the existing themes and create a new one by forking and - creating a pull request. -

-

- Once you're done, you can test it by adding the following snippet to - your status page: + A theme is a set of CSS variable overrides for light and dark mode + in the @openstatus/theme-store package. If you are + familiar with shadcn, you know the trick (it also lets you override{" "} + --radius). Three ways to get one:

-
-            sessionStorage.setItem("community-theme", "true");
-          
+ +
+ + + +

- Or use the following button to test it on the `status` page slug: + Preview the theme from the builder on a full status page (the{" "} + status slug). Unchanged themes open the community theme + switcher instead:

- {/* TODO: OR go to the status-page config and click on the View and Configure button */} +

+ Once you are happy, copy the TypeScript file into{" "} + packages/theme-store/src, register it in{" "} + index.ts and open a pull request. Or paste the CSS + variables into your own status page's custom theme in the + dashboard, no pull request needed. +

diff --git a/apps/status-page/src/app/(public)/layout.tsx b/apps/status-page/src/app/(public)/layout.tsx index 6949cc48..771ac999 100644 --- a/apps/status-page/src/app/(public)/layout.tsx +++ b/apps/status-page/src/app/(public)/layout.tsx @@ -12,6 +12,7 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { Link } from "../../components/common/link"; +import { ThemeBuilderProvider } from "../../components/themes/theme-builder-provider"; import { ThemeProvider } from "../../components/themes/theme-provider"; import { SidebarTrigger, @@ -71,18 +72,20 @@ export default async function Layout({ } as React.CSSProperties } > - - -
{children}
-
-

- powered by{" "} - openstatus -

-
-
- + + + +
{children}
+
+

+ powered by{" "} + openstatus +

+
+
+ +
diff --git a/apps/status-page/src/components/status-page/floating-button.tsx b/apps/status-page/src/components/status-page/floating-button.tsx index d69ffd33..4361f4b9 100644 --- a/apps/status-page/src/components/status-page/floating-button.tsx +++ b/apps/status-page/src/components/status-page/floating-button.tsx @@ -5,6 +5,7 @@ import { type CustomTheme, THEMES, THEME_KEYS, + type Theme, type ThemeDefinition, generateThemeStyles, hasCustomTheme, @@ -38,6 +39,7 @@ import { parseAsString, useQueryState } from "nuqs"; import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; +import { clearThemeDraft } from "../../lib/theme-draft"; import { ThemeSelect } from "../themes/theme-select"; export const IS_DEV = process.env.NODE_ENV === "development"; @@ -68,6 +70,9 @@ interface StatusPageContextType { setNumberOfDays: (numberOfDays: NumberOfDays) => void; communityTheme: CommunityTheme; setCommunityTheme: (communityTheme: CommunityTheme) => void; + /** Unregistered theme previewed from the explorer's builder. */ + draftTheme: Theme | null; + setDraftTheme: (draftTheme: Theme | null) => void; } const StatusPageContext = createContext(null); @@ -102,9 +107,17 @@ export function StatusPageProvider({ const [showUptime, setShowUptime] = useState(defaultShowUptime); const [numberOfDays, setNumberOfDays] = useState(defaultNumberOfDays); - const [communityTheme, setCommunityTheme] = useState( + const [communityTheme, setCommunityThemeState] = useState( defaultCommunityTheme, ); + const [draftTheme, setDraftTheme] = useState(null); + + // a draft shadows the community theme, so picking one discards the draft + function setCommunityTheme(theme: CommunityTheme) { + clearThemeDraft(); + setDraftTheme(null); + setCommunityThemeState(theme); + } const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -112,17 +125,22 @@ export function StatusPageProvider({ }, []); useEffect(() => { - if (isMounted) { - // keep the page's custom vars applied — without them this rewrite - // clobbers the server-rendered overrides on hydration - recomputeStyles( - communityTheme, - hasCustomTheme(customTheme) - ? sanitizeCustomTheme(customTheme) - : undefined, - ); + if (!isMounted) return; + if (draftTheme) { + // an unregistered id falls back to the default theme as base, which is + // what the draft would render over once registered + recomputeStyles(draftTheme.id, draftTheme); + return; } - }, [communityTheme, isMounted, customTheme]); + // keep the page's custom vars applied — without them this rewrite + // clobbers the server-rendered overrides on hydration + recomputeStyles( + communityTheme, + hasCustomTheme(customTheme) + ? sanitizeCustomTheme(customTheme) + : undefined, + ); + }, [communityTheme, isMounted, customTheme, draftTheme]); return ( {children} @@ -386,8 +406,9 @@ export function FloatingButton({ ); } +/** Unregistered ids (builder drafts) fall back to the default theme as base. */ export function recomputeStyles( - newTheme: CommunityTheme, + newTheme: string, overrides?: Partial, ) { try { diff --git a/apps/status-page/src/components/status-page/floating-theme.tsx b/apps/status-page/src/components/status-page/floating-theme.tsx index f7f54df7..2ccf12cd 100644 --- a/apps/status-page/src/components/status-page/floating-theme.tsx +++ b/apps/status-page/src/components/status-page/floating-theme.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, Expand, Theme } from "@openstatus/icons"; +import { Check, Close, Expand, Theme } from "@openstatus/icons"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -21,6 +21,7 @@ import { cn } from "@openstatus/ui/lib/utils"; import { useEffect } from "react"; import { useState } from "react"; +import { clearThemeDraft, readThemeDraft } from "../../lib/theme-draft"; import { ThemeSelect } from "../themes/theme-select"; import { useStatusPage } from "./floating-button"; @@ -28,7 +29,8 @@ export const COMMUNITY_THEME = THEME_KEYS; export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; export function FloatingTheme({ className }: { className?: string }) { - const { communityTheme, setCommunityTheme } = useStatusPage(); + const { communityTheme, setCommunityTheme, draftTheme, setDraftTheme } = + useStatusPage(); const [display, setDisplay] = useState(false); const [open, setOpen] = useState(false); @@ -44,8 +46,14 @@ export function FloatingTheme({ className }: { className?: string }) { ) { setDisplay(true); setOpen(true); + setDraftTheme(readThemeDraft()); } - }, []); + }, [setDraftTheme]); + + function discardDraft() { + clearThemeDraft(); + setDraftTheme(null); + } if (!display) return null; @@ -70,61 +78,88 @@ export function FloatingTheme({ className }: { className?: string }) { -
- - - + {draftTheme ? ( +
+ +
+
+
{draftTheme.name}
+
+ {draftTheme.id} +
+
- - - - - - No themes found. - - {COMMUNITY_THEME.map((theme) => ( - - setCommunityTheme(v as CommunityTheme) - } - > - - {THEMES[theme].name} - - - by {THEMES[theme].author.name} - - - - ))} - - - - - -
+
+

+ Previewing the theme from the builder. Discard it to switch + between community themes. +

+
+ ) : ( +
+ + + + + + + + + + No themes found. + + {COMMUNITY_THEME.map((theme) => ( + + setCommunityTheme(v as CommunityTheme) + } + > + + {THEMES[theme].name} + + + by {THEMES[theme].author.name} + + + + ))} + + + + + +
+ )} diff --git a/apps/status-page/src/components/themes/theme-agent-actions.tsx b/apps/status-page/src/components/themes/theme-agent-actions.tsx new file mode 100644 index 00000000..196369ed --- /dev/null +++ b/apps/status-page/src/components/themes/theme-agent-actions.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Agent } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; + +import { generateThemePrompt } from "../../lib/theme-prompt"; +import { useThemeBuilder } from "./theme-builder-provider"; + +/** Copies a prompt carrying the builder theme for the user's own agent. */ +export function ThemePromptButton({ + onClick, + ...props +}: React.ComponentProps) { + const { theme } = useThemeBuilder(); + const { copy } = useCopyToClipboard(); + return ( + + ); +} diff --git a/apps/status-page/src/components/themes/theme-builder-provider.tsx b/apps/status-page/src/components/themes/theme-builder-provider.tsx new file mode 100644 index 00000000..6cb5f2c9 --- /dev/null +++ b/apps/status-page/src/components/themes/theme-builder-provider.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { THEMES, type Theme } from "@openstatus/theme-store"; +import { useQueryStates } from "nuqs"; +import { createContext, useContext, useEffect, useState } from "react"; + +import { searchParamsParsers } from "../../app/(public)/search-params"; + +interface ThemeBuilderContextType { + /** Theme in the builder: the selected theme plus the user's edits. */ + theme: Theme; + setTheme: React.Dispatch>; + /** True once the builder theme differs from the selected registered theme. */ + isModified: boolean; + reset: () => void; +} + +const ThemeBuilderContext = createContext(null); + +export function useThemeBuilder() { + const context = useContext(ThemeBuilderContext); + if (!context) { + throw new Error("useThemeBuilder must be used within ThemeBuilderProvider"); + } + return context; +} + +export function ThemeBuilderProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [{ t }] = useQueryStates(searchParamsParsers); + const [theme, setTheme] = useState(THEMES[t]); + + useEffect(() => { + setTheme(THEMES[t]); + }, [t]); + + const isModified = JSON.stringify(theme) !== JSON.stringify(THEMES[t]); + + return ( + setTheme(THEMES[t]) }} + > + {children} + + ); +} diff --git a/apps/status-page/src/components/themes/theme-paste-dialog.tsx b/apps/status-page/src/components/themes/theme-paste-dialog.tsx new file mode 100644 index 00000000..351c7004 --- /dev/null +++ b/apps/status-page/src/components/themes/theme-paste-dialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Check, Code } from "@openstatus/icons"; +import { + type ParsedThemeInput, + type Theme, + type ThemeMode, + parseThemeInput, +} from "@openstatus/theme-store"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@openstatus/ui/components/ui/dialog"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { useThemeBuilder } from "./theme-builder-provider"; + +function applyParsedTheme(theme: Theme, parsed: ParsedThemeInput): Theme { + if (parsed.partial) { + return { + ...theme, + ...parsed.info, + light: { ...theme.light, ...parsed.definition.light }, + dark: { ...theme.dark, ...parsed.definition.dark }, + }; + } + return { ...theme, ...parsed.info, ...parsed.definition }; +} + +/** Applies a pasted theme (JSON, `.ts` file, CSS or bare declarations) to the builder. */ +export function ThemePasteDialog(props: React.ComponentProps) { + const { setTheme } = useThemeBuilder(); + const { resolvedTheme } = useTheme(); + const [open, setOpen] = useState(false); + const [text, setText] = useState(""); + const mode: ThemeMode = resolvedTheme === "dark" ? "dark" : "light"; + + function apply() { + const result = parseThemeInput(text, mode); + if (!result.ok) { + toast.error(result.error); + return; + } + setTheme((prev) => applyParsedTheme(prev, result)); + if (result.warnings.length > 0) { + toast.warning( + `Applied with ${result.warnings.length} skipped ${ + result.warnings.length === 1 ? "entry" : "entries" + }`, + { description: result.warnings[0] }, + ); + } else { + toast.success("Theme applied"); + } + setOpen(false); + } + + function onOpenChange(next: boolean) { + setOpen(next); + if (!next) setText(""); + } + + return ( + + + + + + + Paste a theme + + JSON or the TypeScript theme file, as your agent or the copy button + produced it. :root / .dark CSS blocks work + too, and bare --name: value; lines apply to the {mode}{" "} + mode. + + +