@tulip-systems/core 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/components/client.d.mts +2 -1
  2. package/dist/components/client.mjs +2 -1
  3. package/dist/components/editor/extensions/file-handler/extension.d.mts +1 -1
  4. package/dist/components/editor/extensions/image/extension.d.mts +1 -1
  5. package/dist/components/editor/extensions/skeleton/extension.mjs +1 -1
  6. package/dist/components/editor/lib/constants.d.mts +1 -1
  7. package/dist/components/editor/lib/extensions.d.mts +1 -1
  8. package/dist/components/editor/lib/helpers.d.mts +5 -1
  9. package/dist/components/editor/lib/helpers.mjs +8 -1
  10. package/dist/components/layouts/root-layout.server.d.mts +3 -2
  11. package/dist/components/layouts/root-layout.server.mjs +1 -3
  12. package/dist/components/server.d.mts +2 -2
  13. package/dist/components/themes/color-theme-provider.client.d.mts +27 -0
  14. package/dist/components/themes/color-theme-provider.client.mjs +59 -0
  15. package/dist/components/themes/color-theme.d.mts +29 -0
  16. package/dist/components/themes/color-theme.mjs +32 -0
  17. package/dist/components/ui/badge.d.mts +1 -1
  18. package/dist/components/ui/button-group.d.mts +1 -1
  19. package/dist/components/ui/button.d.mts +2 -2
  20. package/dist/components/ui/field.client.d.mts +1 -1
  21. package/dist/components/ui/item.d.mts +1 -1
  22. package/dist/components.d.mts +3 -2
  23. package/dist/components.mjs +3 -2
  24. package/dist/modules/auth/handler/create-client.client.d.mts +4 -4
  25. package/dist/modules/inline/lib/variants.d.mts +1 -1
  26. package/dist/modules/storage/components/dropzone.client.d.mts +2 -2
  27. package/dist/modules/storage/lib/service.server.d.mts +15 -15
  28. package/dist/src/components/editor/extensions/file-handler/extension.d.mts +1 -1
  29. package/dist/src/components/editor/extensions/image/extension.d.mts +1 -1
  30. package/dist/src/components/editor/extensions/skeleton/extension.mjs +1 -1
  31. package/dist/src/components/editor/lib/constants.d.mts +1 -1
  32. package/dist/src/components/editor/lib/extensions.d.mts +1 -1
  33. package/dist/src/components/editor/lib/helpers.d.mts +5 -1
  34. package/dist/src/components/editor/lib/helpers.mjs +8 -1
  35. package/dist/src/components/layouts/root-layout.server.d.mts +3 -2
  36. package/dist/src/components/layouts/root-layout.server.mjs +1 -3
  37. package/dist/src/components/themes/color-theme-provider.client.d.mts +27 -0
  38. package/dist/src/components/themes/color-theme-provider.client.mjs +59 -0
  39. package/dist/src/components/themes/color-theme.d.mts +29 -0
  40. package/dist/src/components/themes/color-theme.mjs +32 -0
  41. package/dist/src/components/ui/badge.d.mts +1 -1
  42. package/dist/src/components/ui/button.d.mts +1 -1
  43. package/dist/src/components/ui/item.d.mts +1 -1
  44. package/dist/src/modules/auth/handler/create-client.client.d.mts +2 -2
  45. package/dist/src/modules/inline/lib/variants.d.mts +1 -1
  46. package/dist/src/modules/storage/lib/service.server.d.mts +21 -21
  47. package/package.json +1 -1
  48. package/src/components/editor/lib/helpers.ts +8 -0
  49. package/src/components/entry.client.ts +1 -1
  50. package/src/components/entry.ts +1 -1
  51. package/src/components/layouts/root-layout.server.tsx +4 -2
  52. package/src/components/themes/color-theme-provider.client.tsx +82 -0
  53. package/src/components/themes/color-theme.ts +32 -0
  54. package/src/styles.css +226 -2
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { SkeletonNodeRenderer } from "./renderer.mjs";
4
- import { ReactNodeViewRenderer } from "@tiptap/react";
5
4
  import { Node } from "@tiptap/core";
5
+ import { ReactNodeViewRenderer } from "@tiptap/react";
6
6
 
7
7
  //#region src/components/editor/extensions/skeleton/extension.ts
8
8
  const Skeleton = Node.create({
@@ -1,5 +1,6 @@
1
1
  import { FileHandlerOptions } from "../extensions/file-handler/extension.mjs";
2
2
  import { ImageOptions } from "../extensions/image/extension.mjs";
3
+ import * as _tiptap_core0 from "@tiptap/core";
3
4
  import * as _tiptap_extension_blockquote0 from "@tiptap/extension-blockquote";
4
5
  import * as _tiptap_extension_bold0 from "@tiptap/extension-bold";
5
6
  import * as _tiptap_extension_hard_break0 from "@tiptap/extension-hard-break";
@@ -14,7 +15,6 @@ import * as _tiptap_extension_underline0 from "@tiptap/extension-underline";
14
15
  import * as _tiptap_extension_highlight0 from "@tiptap/extension-highlight";
15
16
  import * as _tiptap_extension_text_style0 from "@tiptap/extension-text-style";
16
17
  import * as _tiptap_extensions0 from "@tiptap/extensions";
17
- import * as _tiptap_core0 from "@tiptap/core";
18
18
 
19
19
  //#region src/components/editor/lib/constants.d.ts
20
20
  /**
@@ -1,6 +1,7 @@
1
1
  import { FileHandlerOptions } from "../extensions/file-handler/extension.mjs";
2
2
  import { ImageOptions } from "../extensions/image/extension.mjs";
3
3
  import { EditorExtensions } from "./constants.mjs";
4
+ import "@tiptap/core";
4
5
  import { BlockquoteOptions } from "@tiptap/extension-blockquote";
5
6
  import { BoldOptions } from "@tiptap/extension-bold";
6
7
  import { HardBreakOptions } from "@tiptap/extension-hard-break";
@@ -16,7 +17,6 @@ import "@tiptap/react";
16
17
  import { HighlightOptions } from "@tiptap/extension-highlight";
17
18
  import { ColorOptions, TextStyleOptions } from "@tiptap/extension-text-style";
18
19
  import { DropcursorOptions, TrailingNodeOptions, UndoRedoOptions } from "@tiptap/extensions";
19
- import "@tiptap/core";
20
20
 
21
21
  //#region src/components/editor/lib/extensions.d.ts
22
22
  /**
@@ -17,5 +17,9 @@ declare function convertEditorContentToHTML(content: EditorJSONContent): string;
17
17
  * Convert editor content to markdown
18
18
  */
19
19
  declare function convertEditorContentToMarkdown(content: EditorJSONContent): string;
20
+ /**
21
+ * Convert editor content to plain text
22
+ */
23
+ declare function convertEditorContentToPlainText(content: EditorJSONContent): string;
20
24
  //#endregion
21
- export { convertEditorContentToHTML, convertEditorContentToMarkdown, convertHTMLToEditorContent, convertMarkdownToEditorContent };
25
+ export { convertEditorContentToHTML, convertEditorContentToMarkdown, convertEditorContentToPlainText, convertHTMLToEditorContent, convertMarkdownToEditorContent };
@@ -1,4 +1,5 @@
1
1
  import { markdownParser } from "../../../lib/utils/markdown.mjs";
2
+ import { generateText } from "@tiptap/core";
2
3
  import Blockquote from "@tiptap/extension-blockquote";
3
4
  import Bold from "@tiptap/extension-bold";
4
5
  import Document from "@tiptap/extension-document";
@@ -63,6 +64,12 @@ function convertEditorContentToMarkdown(content) {
63
64
  extensions: EXTENSIONS_FOR_GENERATION
64
65
  });
65
66
  }
67
+ /**
68
+ * Convert editor content to plain text
69
+ */
70
+ function convertEditorContentToPlainText(content) {
71
+ return generateText(content, EXTENSIONS_FOR_GENERATION);
72
+ }
66
73
 
67
74
  //#endregion
68
- export { convertEditorContentToHTML, convertEditorContentToMarkdown, convertHTMLToEditorContent, convertMarkdownToEditorContent };
75
+ export { convertEditorContentToHTML, convertEditorContentToMarkdown, convertEditorContentToPlainText, convertHTMLToEditorContent, convertMarkdownToEditorContent };
@@ -29,8 +29,9 @@ declare const generateRootLayoutMetadata: (config: TulipConfig) => {
29
29
  /**
30
30
  * Root layout
31
31
  */
32
+ type RootLayoutProps = PropsWithChildren;
32
33
  declare function RootLayout({
33
34
  children
34
- }: PropsWithChildren): react_jsx_runtime0.JSX.Element;
35
+ }: RootLayoutProps): react_jsx_runtime0.JSX.Element;
35
36
  //#endregion
36
- export { RootLayout, generateRootLayoutMetadata };
37
+ export { RootLayout, RootLayoutProps, generateRootLayoutMetadata };
@@ -29,12 +29,10 @@ const generateRootLayoutMetadata = (config) => ({
29
29
  }]
30
30
  }
31
31
  });
32
- /**
33
- * Root layout
34
- */
35
32
  function RootLayout({ children }) {
36
33
  return /* @__PURE__ */ jsx("html", {
37
34
  lang: "nl",
35
+ "data-theme": "tulip",
38
36
  children: /* @__PURE__ */ jsx("body", {
39
37
  className: cn("bg-background font-sans antialiased", registerFonts()),
40
38
  children
@@ -0,0 +1,27 @@
1
+ import { ColorTheme } from "./color-theme.mjs";
2
+ import * as react_jsx_runtime0 from "react/jsx-runtime";
3
+ import { PropsWithChildren } from "react";
4
+
5
+ //#region src/components/themes/color-theme-provider.client.d.ts
6
+ type ColorThemeContextValue = {
7
+ colorTheme: ColorTheme;
8
+ setColorTheme: (theme: ColorTheme) => void;
9
+ colorThemes: readonly ColorTheme[];
10
+ };
11
+ /**
12
+ * Provides the current color theme and a setter to the component tree.
13
+ * The server provides the initial theme, then client interactions keep local state and the cookie in sync.
14
+ */
15
+ declare function ColorThemeProvider({
16
+ children,
17
+ theme
18
+ }: PropsWithChildren<{
19
+ theme: ColorTheme;
20
+ }>): react_jsx_runtime0.JSX.Element;
21
+ /**
22
+ * Returns the active color theme context for client components.
23
+ * It throws early when used outside the provider so incorrect usage fails loudly.
24
+ */
25
+ declare function useColorTheme(): ColorThemeContextValue;
26
+ //#endregion
27
+ export { ColorThemeProvider, useColorTheme };
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { COLOR_THEME_STORAGE_KEY, colorThemes } from "./color-theme.mjs";
4
+ import { jsx } from "react/jsx-runtime";
5
+ import { createContext, use, useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
6
+
7
+ //#region src/components/themes/color-theme-provider.client.tsx
8
+ const ColorThemeContext = createContext(null);
9
+ /**
10
+ * Persists the chosen theme in a cookie so the server can resolve it on the next request.
11
+ * Cookie failures are ignored because theme changes should still work in local React state.
12
+ */
13
+ function setStoredColorTheme(theme) {
14
+ try {
15
+ document.cookie = `${COLOR_THEME_STORAGE_KEY}=${theme}; path=/; max-age=31536000; samesite=lax`;
16
+ } catch {}
17
+ }
18
+ /**
19
+ * Provides the current color theme and a setter to the component tree.
20
+ * The server provides the initial theme, then client interactions keep local state and the cookie in sync.
21
+ */
22
+ function ColorThemeProvider({ children, theme }) {
23
+ const [colorTheme, setColorThemeState] = useState(theme);
24
+ useEffect(() => {
25
+ setColorThemeState(theme);
26
+ }, [theme]);
27
+ useLayoutEffect(() => {
28
+ document.documentElement.dataset.theme = colorTheme;
29
+ }, [colorTheme]);
30
+ const setColorTheme = useCallback((theme) => {
31
+ setColorThemeState(theme);
32
+ setStoredColorTheme(theme);
33
+ }, []);
34
+ const value = useMemo(() => ({
35
+ colorTheme,
36
+ setColorTheme,
37
+ colorThemes
38
+ }), [colorTheme, setColorTheme]);
39
+ return /* @__PURE__ */ jsx(ColorThemeContext.Provider, {
40
+ value,
41
+ children: /* @__PURE__ */ jsx("div", {
42
+ className: "contents",
43
+ "data-theme": colorTheme,
44
+ children
45
+ })
46
+ });
47
+ }
48
+ /**
49
+ * Returns the active color theme context for client components.
50
+ * It throws early when used outside the provider so incorrect usage fails loudly.
51
+ */
52
+ function useColorTheme() {
53
+ const context = use(ColorThemeContext);
54
+ if (!context) throw new Error("useColorTheme must be used within a ColorThemeProvider");
55
+ return context;
56
+ }
57
+
58
+ //#endregion
59
+ export { ColorThemeProvider, useColorTheme };
@@ -0,0 +1,29 @@
1
+ import z from "zod";
2
+ import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
3
+
4
+ //#region src/components/themes/color-theme.d.ts
5
+ /**
6
+ * Shared cookie key used by both server reads and client writes.
7
+ */
8
+ declare const COLOR_THEME_STORAGE_KEY = "tulip-color-theme";
9
+ /**
10
+ * All supported color theme names.
11
+ */
12
+ declare const colorThemes: readonly ["tulip", "rose", "iris", "sage"];
13
+ type ColorTheme = (typeof colorThemes)[number];
14
+ /**
15
+ * Validators
16
+ */
17
+ declare const colorThemeSchema: z.ZodEnum<{
18
+ tulip: "tulip";
19
+ rose: "rose";
20
+ iris: "iris";
21
+ sage: "sage";
22
+ }>;
23
+ /**
24
+ * Resolves a validated color theme from either a raw string value or a cookie store.
25
+ * Invalid, missing, or unsupported values are treated as undefined.
26
+ */
27
+ declare function getColorThemeValue(cookieStore: ReadonlyRequestCookies, fallback?: ColorTheme): ColorTheme;
28
+ //#endregion
29
+ export { COLOR_THEME_STORAGE_KEY, ColorTheme, colorThemeSchema, colorThemes, getColorThemeValue };
@@ -0,0 +1,32 @@
1
+ import z from "zod";
2
+
3
+ //#region src/components/themes/color-theme.ts
4
+ /**
5
+ * Shared cookie key used by both server reads and client writes.
6
+ */
7
+ const COLOR_THEME_STORAGE_KEY = "tulip-color-theme";
8
+ /**
9
+ * All supported color theme names.
10
+ */
11
+ const colorThemes = [
12
+ "tulip",
13
+ "rose",
14
+ "iris",
15
+ "sage"
16
+ ];
17
+ /**
18
+ * Validators
19
+ */
20
+ const colorThemeSchema = z.enum(colorThemes, { error: "Invalid color theme" });
21
+ /**
22
+ * Resolves a validated color theme from either a raw string value or a cookie store.
23
+ * Invalid, missing, or unsupported values are treated as undefined.
24
+ */
25
+ function getColorThemeValue(cookieStore, fallback = "tulip") {
26
+ const theme = cookieStore.get(COLOR_THEME_STORAGE_KEY)?.value;
27
+ const parsed = colorThemeSchema.safeParse(theme);
28
+ return parsed.success ? parsed.data : fallback;
29
+ }
30
+
31
+ //#endregion
32
+ export { COLOR_THEME_STORAGE_KEY, colorThemeSchema, colorThemes, getColorThemeValue };
@@ -5,7 +5,7 @@ import * as class_variance_authority_types0 from "class-variance-authority/types
5
5
 
6
6
  //#region src/components/ui/badge.d.ts
7
7
  declare const badgeVariants: (props?: ({
8
- variant?: "default" | "link" | "outline" | "destructive" | "secondary" | "ghost" | null | undefined;
8
+ variant?: "link" | "default" | "outline" | "destructive" | "secondary" | "ghost" | null | undefined;
9
9
  } & class_variance_authority_types0.ClassProp) | undefined) => string;
10
10
  declare function Badge({
11
11
  className,
@@ -5,7 +5,7 @@ import * as class_variance_authority_types0 from "class-variance-authority/types
5
5
 
6
6
  //#region src/components/ui/button.d.ts
7
7
  declare const buttonVariants: (props?: ({
8
- variant?: "default" | "link" | "outline" | "destructive" | "secondary" | "ghost" | null | undefined;
8
+ variant?: "link" | "default" | "outline" | "destructive" | "secondary" | "ghost" | null | undefined;
9
9
  size?: "default" | "xs" | "sm" | "icon-xs" | "icon-sm" | "lg" | "icon" | "icon-lg" | null | undefined;
10
10
  } & class_variance_authority_types0.ClassProp) | undefined) => string;
11
11
  declare function Button({
@@ -27,7 +27,7 @@ declare function Item({
27
27
  asChild?: boolean;
28
28
  }): react_jsx_runtime0.JSX.Element;
29
29
  declare const itemMediaVariants: (props?: ({
30
- variant?: "default" | "image" | "icon" | null | undefined;
30
+ variant?: "image" | "default" | "icon" | null | undefined;
31
31
  } & class_variance_authority_types0.ClassProp) | undefined) => string;
32
32
  declare function ItemMedia({
33
33
  className,
@@ -192,7 +192,7 @@ declare function createAuthClient<TAccessControl extends AccessControl, TRoles e
192
192
  sortBy?: string | undefined;
193
193
  sortDirection?: "asc" | "desc" | undefined;
194
194
  filterField?: string | undefined;
195
- filterValue?: string | number | boolean | number[] | string[] | undefined;
195
+ filterValue?: string | number | boolean | string[] | number[] | undefined;
196
196
  filterOperator?: "in" | "contains" | "starts_with" | "ends_with" | "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "not_in" | undefined;
197
197
  }> & Record<string, any>, Record<string, any> | undefined>>(data_0: better_auth0.Prettify<{
198
198
  query: {
@@ -204,7 +204,7 @@ declare function createAuthClient<TAccessControl extends AccessControl, TRoles e
204
204
  sortBy?: string | undefined;
205
205
  sortDirection?: "asc" | "desc" | undefined;
206
206
  filterField?: string | undefined;
207
- filterValue?: string | number | boolean | number[] | string[] | undefined;
207
+ filterValue?: string | number | boolean | string[] | number[] | undefined;
208
208
  filterOperator?: "in" | "contains" | "starts_with" | "ends_with" | "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "not_in" | undefined;
209
209
  };
210
210
  fetchOptions?: FetchOptions | undefined;
@@ -4,7 +4,7 @@ import * as class_variance_authority_types0 from "class-variance-authority/types
4
4
  //#region src/modules/inline/lib/variants.d.ts
5
5
  declare const inlineEditVariants: (props?: ({
6
6
  variant?: "default" | "table" | null | undefined;
7
- status?: "success" | "error" | "pending" | "idle" | null | undefined;
7
+ status?: "error" | "success" | "pending" | "idle" | null | undefined;
8
8
  } & class_variance_authority_types0.ClassProp) | undefined) => string;
9
9
  type InlineEditVariantsProps = VariantProps<typeof inlineEditVariants>;
10
10
  //#endregion
@@ -1915,19 +1915,19 @@ declare class Storage<TSchema extends TDatabaseSchema> {
1915
1915
  */
1916
1916
  presignUpload(props: PresignUploadInput): Promise<{
1917
1917
  presignedUrl: string;
1918
- contentType: string | null;
1919
- size: number | null;
1920
- metadata: unknown;
1921
- key: string;
1922
- status: "error" | "pending" | "ready";
1923
1918
  id: string;
1924
1919
  createdAt: Date;
1925
1920
  updatedAt: Date;
1926
1921
  name: string | null;
1922
+ key: string;
1923
+ metadata: unknown;
1927
1924
  provider: "s3";
1928
- bucket: string;
1929
- visibility: "private" | "public";
1930
1925
  uploadId: string;
1926
+ visibility: "private" | "public";
1927
+ size: number | null;
1928
+ contentType: string | null;
1929
+ bucket: string;
1930
+ status: "error" | "pending" | "ready";
1931
1931
  etag: string | null;
1932
1932
  uploadedAt: Date;
1933
1933
  deletedAt: Date | null;
@@ -2164,19 +2164,19 @@ declare class Storage<TSchema extends TDatabaseSchema> {
2164
2164
  * @returns The purged asset record, or null if not found
2165
2165
  */
2166
2166
  purgeAsset(input: string): Promise<{
2167
- contentType: string | null;
2168
- size: number | null;
2169
- metadata: unknown;
2170
- key: string;
2171
- status: "error" | "pending" | "ready";
2172
2167
  id: string;
2173
2168
  createdAt: Date;
2174
2169
  updatedAt: Date;
2175
2170
  name: string | null;
2171
+ key: string;
2172
+ metadata: unknown;
2176
2173
  provider: "s3";
2177
- bucket: string;
2178
- visibility: "private" | "public";
2179
2174
  uploadId: string;
2175
+ visibility: "private" | "public";
2176
+ size: number | null;
2177
+ contentType: string | null;
2178
+ bucket: string;
2179
+ status: "error" | "pending" | "ready";
2180
2180
  etag: string | null;
2181
2181
  uploadedAt: Date;
2182
2182
  deletedAt: Date | null;
@@ -2195,19 +2195,19 @@ declare class Storage<TSchema extends TDatabaseSchema> {
2195
2195
  * @throws {ServerError} If provider deletion fails
2196
2196
  */
2197
2197
  purgeAssets(input: string[]): Promise<{
2198
- contentType: string | null;
2199
- size: number | null;
2200
- metadata: unknown;
2201
- key: string;
2202
- status: "error" | "pending" | "ready";
2203
2198
  id: string;
2204
2199
  createdAt: Date;
2205
2200
  updatedAt: Date;
2206
2201
  name: string | null;
2202
+ key: string;
2203
+ metadata: unknown;
2207
2204
  provider: "s3";
2208
- bucket: string;
2209
- visibility: "private" | "public";
2210
2205
  uploadId: string;
2206
+ visibility: "private" | "public";
2207
+ size: number | null;
2208
+ contentType: string | null;
2209
+ bucket: string;
2210
+ status: "error" | "pending" | "ready";
2211
2211
  etag: string | null;
2212
2212
  uploadedAt: Date;
2213
2213
  deletedAt: Date | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tulip-systems/core",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "AGPL-3.0",
@@ -1,3 +1,4 @@
1
+ import { generateText } from "@tiptap/core";
1
2
  import Blockquote from "@tiptap/extension-blockquote";
2
3
  import Bold from "@tiptap/extension-bold";
3
4
  import Document from "@tiptap/extension-document";
@@ -68,3 +69,10 @@ export function convertEditorContentToMarkdown(content: EditorJSONContent): stri
68
69
  extensions: EXTENSIONS_FOR_GENERATION,
69
70
  });
70
71
  }
72
+
73
+ /**
74
+ * Convert editor content to plain text
75
+ */
76
+ export function convertEditorContentToPlainText(content: EditorJSONContent): string {
77
+ return generateText(content, EXTENSIONS_FOR_GENERATION);
78
+ }
@@ -20,11 +20,11 @@ export * from "./header/top-bar.client";
20
20
  */
21
21
  export * from "./layouts/admin-content.client";
22
22
  export * from "./layouts/providers.client";
23
-
24
23
  /**
25
24
  * Components Navigation
26
25
  */
27
26
  export * from "./navigation/admin-sidebar-paths.client";
27
+ export * from "./themes/color-theme-provider.client";
28
28
 
29
29
  /**
30
30
  * Components UI
@@ -19,12 +19,12 @@ export * from "./layouts/list-layout";
19
19
  export * from "./layouts/root-error-pages";
20
20
  export * from "./layouts/root-loading";
21
21
  export * from "./layouts/tab-layout";
22
-
23
22
  /**
24
23
  * Components Lists
25
24
  */
26
25
  export * from "./lists/data-list";
27
26
  export * from "./lists/data-stack";
27
+ export * from "./themes/color-theme";
28
28
 
29
29
  /**
30
30
  * Components UI
@@ -38,9 +38,11 @@ export const generateRootLayoutMetadata = (config: TulipConfig) => ({
38
38
  /**
39
39
  * Root layout
40
40
  */
41
- export function RootLayout({ children }: PropsWithChildren) {
41
+ export type RootLayoutProps = PropsWithChildren;
42
+
43
+ export function RootLayout({ children }: RootLayoutProps) {
42
44
  return (
43
- <html lang="nl">
45
+ <html lang="nl" data-theme="tulip">
44
46
  <body className={cn("bg-background font-sans antialiased", registerFonts())}>{children}</body>
45
47
  </html>
46
48
  );
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import type { PropsWithChildren } from "react";
4
+ import {
5
+ createContext,
6
+ use,
7
+ useCallback,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useState,
12
+ } from "react";
13
+ import { COLOR_THEME_STORAGE_KEY, type ColorTheme, colorThemes } from "./color-theme";
14
+
15
+ type ColorThemeContextValue = {
16
+ colorTheme: ColorTheme;
17
+ setColorTheme: (theme: ColorTheme) => void;
18
+ colorThemes: readonly ColorTheme[];
19
+ };
20
+
21
+ const ColorThemeContext = createContext<ColorThemeContextValue | null>(null);
22
+
23
+ /**
24
+ * Persists the chosen theme in a cookie so the server can resolve it on the next request.
25
+ * Cookie failures are ignored because theme changes should still work in local React state.
26
+ */
27
+ function setStoredColorTheme(theme: ColorTheme) {
28
+ try {
29
+ // biome-ignore lint/suspicious/noDocumentCookie: client theme changes need to persist for the next server render
30
+ document.cookie = `${COLOR_THEME_STORAGE_KEY}=${theme}; path=/; max-age=31536000; samesite=lax`;
31
+ } catch {
32
+ // Ignore cookie failures.
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Provides the current color theme and a setter to the component tree.
38
+ * The server provides the initial theme, then client interactions keep local state and the cookie in sync.
39
+ */
40
+ export function ColorThemeProvider({ children, theme }: PropsWithChildren<{ theme: ColorTheme }>) {
41
+ const [colorTheme, setColorThemeState] = useState(theme);
42
+
43
+ useEffect(() => {
44
+ setColorThemeState(theme);
45
+ }, [theme]);
46
+
47
+ useLayoutEffect(() => {
48
+ document.documentElement.dataset.theme = colorTheme;
49
+ }, [colorTheme]);
50
+
51
+ const setColorTheme = useCallback((theme: ColorTheme) => {
52
+ setColorThemeState(theme);
53
+ setStoredColorTheme(theme);
54
+ }, []);
55
+
56
+ const value = useMemo(
57
+ () => ({ colorTheme, setColorTheme, colorThemes }),
58
+ [colorTheme, setColorTheme],
59
+ );
60
+
61
+ return (
62
+ <ColorThemeContext.Provider value={value}>
63
+ <div className="contents" data-theme={colorTheme}>
64
+ {children}
65
+ </div>
66
+ </ColorThemeContext.Provider>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Returns the active color theme context for client components.
72
+ * It throws early when used outside the provider so incorrect usage fails loudly.
73
+ */
74
+ export function useColorTheme() {
75
+ const context = use(ColorThemeContext);
76
+
77
+ if (!context) {
78
+ throw new Error("useColorTheme must be used within a ColorThemeProvider");
79
+ }
80
+
81
+ return context;
82
+ }
@@ -0,0 +1,32 @@
1
+ import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
2
+ import z from "zod";
3
+
4
+ /**
5
+ * Shared cookie key used by both server reads and client writes.
6
+ */
7
+ export const COLOR_THEME_STORAGE_KEY = "tulip-color-theme";
8
+
9
+ /**
10
+ * All supported color theme names.
11
+ */
12
+ export const colorThemes = ["tulip", "rose", "iris", "sage"] as const;
13
+ export type ColorTheme = (typeof colorThemes)[number];
14
+
15
+ /**
16
+ * Validators
17
+ */
18
+ export const colorThemeSchema = z.enum(colorThemes, { error: "Invalid color theme" });
19
+
20
+ /**
21
+ * Resolves a validated color theme from either a raw string value or a cookie store.
22
+ * Invalid, missing, or unsupported values are treated as undefined.
23
+ */
24
+ export function getColorThemeValue(
25
+ cookieStore: ReadonlyRequestCookies,
26
+ fallback: ColorTheme = "tulip",
27
+ ): ColorTheme {
28
+ const theme = cookieStore.get(COLOR_THEME_STORAGE_KEY)?.value;
29
+ const parsed = colorThemeSchema.safeParse(theme);
30
+
31
+ return parsed.success ? parsed.data : fallback;
32
+ }