@katechat/ui 1.0.0

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 (47) hide show
  1. package/.prettierrc +9 -0
  2. package/esbuild.js +56 -0
  3. package/jest.config.js +24 -0
  4. package/package.json +75 -0
  5. package/postcss.config.cjs +14 -0
  6. package/src/__mocks__/fileMock.js +1 -0
  7. package/src/__mocks__/styleMock.js +1 -0
  8. package/src/components/chat/ChatMessagesContainer.module.scss +77 -0
  9. package/src/components/chat/ChatMessagesContainer.tsx +151 -0
  10. package/src/components/chat/ChatMessagesList.tsx +216 -0
  11. package/src/components/chat/index.ts +4 -0
  12. package/src/components/chat/input/ChatInput.module.scss +113 -0
  13. package/src/components/chat/input/ChatInput.tsx +259 -0
  14. package/src/components/chat/input/index.ts +1 -0
  15. package/src/components/chat/message/ChatMessage.Carousel.module.scss +7 -0
  16. package/src/components/chat/message/ChatMessage.module.scss +378 -0
  17. package/src/components/chat/message/ChatMessage.tsx +271 -0
  18. package/src/components/chat/message/ChatMessagePreview.tsx +22 -0
  19. package/src/components/chat/message/LinkedChatMessage.tsx +64 -0
  20. package/src/components/chat/message/MessageStatus.tsx +38 -0
  21. package/src/components/chat/message/controls/CopyMessageButton.tsx +32 -0
  22. package/src/components/chat/message/index.ts +4 -0
  23. package/src/components/icons/ProviderIcon.tsx +49 -0
  24. package/src/components/icons/index.ts +1 -0
  25. package/src/components/index.ts +3 -0
  26. package/src/components/modal/ImagePopup.tsx +97 -0
  27. package/src/components/modal/index.ts +1 -0
  28. package/src/controls/FileDropzone/FileDropzone.module.scss +15 -0
  29. package/src/controls/FileDropzone/FileDropzone.tsx +120 -0
  30. package/src/controls/index.ts +1 -0
  31. package/src/core/ai.ts +1 -0
  32. package/src/core/index.ts +4 -0
  33. package/src/core/message.ts +59 -0
  34. package/src/core/model.ts +23 -0
  35. package/src/core/user.ts +8 -0
  36. package/src/hooks/index.ts +2 -0
  37. package/src/hooks/useIntersectionObserver.ts +24 -0
  38. package/src/hooks/useTheme.tsx +66 -0
  39. package/src/index.ts +5 -0
  40. package/src/lib/__tests__/markdown.parser.test.ts +289 -0
  41. package/src/lib/__tests__/markdown.parser.testUtils.ts +31 -0
  42. package/src/lib/__tests__/markdown.parser_sanitizeUrl.test.ts +130 -0
  43. package/src/lib/assert.ts +14 -0
  44. package/src/lib/markdown.parser.ts +189 -0
  45. package/src/setupTests.ts +1 -0
  46. package/src/types/scss.d.ts +4 -0
  47. package/tsconfig.json +26 -0
@@ -0,0 +1,64 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text, Group, Avatar } from "@mantine/core";
3
+ import { Carousel } from "@mantine/carousel";
4
+ import { IconRobot } from "@tabler/icons-react";
5
+
6
+ import { Message, Model } from "@/core";
7
+ import { ProviderIcon } from "@/components/icons/ProviderIcon";
8
+ import { MessageStatus } from "./MessageStatus";
9
+ import { CopyMessageButton } from "./controls/CopyMessageButton";
10
+
11
+ import classes from "./ChatMessage.module.scss";
12
+
13
+ interface IProps {
14
+ message: Message;
15
+ parentIndex: number;
16
+ index: number;
17
+ models?: Model[];
18
+ plugins?: React.ReactNode;
19
+ }
20
+
21
+ export const LinkedChatMessage = ({ message, parentIndex, index, plugins, models }: IProps) => {
22
+ const model = useMemo(() => {
23
+ return models?.find(m => m.modelId === message.modelId);
24
+ }, [models, message.modelId]);
25
+
26
+ return (
27
+ <Carousel.Slide key={message.id} className={classes.linkedMessageContainer}>
28
+ <Group align="center">
29
+ <Avatar radius="xl" size="md">
30
+ {model ? <ProviderIcon apiProvider={model.apiProvider} provider={model.provider} /> : <IconRobot />}
31
+ </Avatar>
32
+ <Group gap="xs">
33
+ <Text size="sm" fw={500} c="teal">
34
+ {message.modelName}
35
+ </Text>
36
+ {message.status && <MessageStatus status={message.status} />}
37
+ {message.statusInfo && (
38
+ <Text size="xs" c="dimmed">
39
+ {message.statusInfo}
40
+ </Text>
41
+ )}
42
+ </Group>
43
+ </Group>
44
+
45
+ <div className={classes.message}>
46
+ {message.html ? (
47
+ message.html.map((part: string, index: number) => (
48
+ <div key={index} dangerouslySetInnerHTML={{ __html: part }} />
49
+ ))
50
+ ) : (
51
+ <div>{message.content}</div>
52
+ )}
53
+
54
+ <div className={classes.messageFooter}>
55
+ <CopyMessageButton messageId={message.id} messageIndex={parentIndex} linkedMessageIndex={index} />
56
+
57
+ {plugins}
58
+ </div>
59
+ </div>
60
+ </Carousel.Slide>
61
+ );
62
+ };
63
+
64
+ LinkedChatMessage.displayName = "LinkedChatMessage";
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import { Badge, DefaultMantineColor } from "@mantine/core";
3
+ import { ResponseStatus } from "@/core/message";
4
+
5
+ const TITLE_MAP: Record<ResponseStatus, string> = {
6
+ [ResponseStatus.IN_PROGRESS]: "In Progress",
7
+ [ResponseStatus.COMPLETED]: "Completed",
8
+ [ResponseStatus.RAG_SEARCH]: "RAG Search",
9
+ [ResponseStatus.WEB_SEARCH]: "Web Search",
10
+ [ResponseStatus.CODE_INTERPRETER]: "Code Interpreter",
11
+ [ResponseStatus.TOOL_CALL]: "Tool Call",
12
+ [ResponseStatus.REASONING]: "Reasoning",
13
+ [ResponseStatus.ERROR]: "Error",
14
+ [ResponseStatus.TOOL_CALL_COMPLETED]: "Tool Call Completed",
15
+ };
16
+
17
+ const COLOR_MAP: Record<ResponseStatus, DefaultMantineColor> = {
18
+ [ResponseStatus.IN_PROGRESS]: "blue",
19
+ [ResponseStatus.COMPLETED]: "green",
20
+ [ResponseStatus.RAG_SEARCH]: "orange",
21
+ [ResponseStatus.WEB_SEARCH]: "teal",
22
+ [ResponseStatus.CODE_INTERPRETER]: "teal",
23
+ [ResponseStatus.TOOL_CALL]: "cyan",
24
+ [ResponseStatus.REASONING]: "yellow",
25
+ [ResponseStatus.ERROR]: "red",
26
+ [ResponseStatus.TOOL_CALL_COMPLETED]: "green",
27
+ };
28
+
29
+ export const MessageStatus = ({ status }: { status: ResponseStatus }) => {
30
+ const title = TITLE_MAP[status] || status;
31
+ const color = COLOR_MAP[status] || "indigo";
32
+
33
+ return (
34
+ <Badge color={color} variant="light">
35
+ {title}
36
+ </Badge>
37
+ );
38
+ };
@@ -0,0 +1,32 @@
1
+ import { ActionIcon, Tooltip } from "@mantine/core";
2
+ import { IconCopy, IconCopyCheck } from "@tabler/icons-react";
3
+ import React from "react";
4
+
5
+ export const CopyMessageButton = ({
6
+ messageId,
7
+ messageIndex,
8
+ linkedMessageIndex,
9
+ }: {
10
+ messageId: string;
11
+ messageIndex: number;
12
+ linkedMessageIndex?: number;
13
+ }) => (
14
+ <>
15
+ <Tooltip label="Copy message" position="top" withArrow>
16
+ <ActionIcon
17
+ className="copy-message-btn"
18
+ data-message-id={messageId}
19
+ data-message-index={messageIndex}
20
+ data-message-linked-index={linkedMessageIndex}
21
+ size="sm"
22
+ color="gray"
23
+ variant="transparent"
24
+ >
25
+ <IconCopy />
26
+ </ActionIcon>
27
+ </Tooltip>
28
+ <ActionIcon disabled size="sm" className="check-icon">
29
+ <IconCopyCheck />
30
+ </ActionIcon>
31
+ </>
32
+ );
@@ -0,0 +1,4 @@
1
+ export * from "./MessageStatus";
2
+ export * from "./ChatMessage";
3
+ export * from "./ChatMessagePreview";
4
+ export * from "./LinkedChatMessage";
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+ import {
3
+ IconBrandOpenai,
4
+ IconBrandAws,
5
+ IconServer,
6
+ IconBrandYandex,
7
+ IconMessageChatbot,
8
+ IconAi,
9
+ IconBrandMeta,
10
+ IconBrandMedium,
11
+ IconBrandGoogle,
12
+ } from "@tabler/icons-react";
13
+ import { ApiProvider } from "@/core/ai";
14
+
15
+ export const ProviderIcon = ({
16
+ apiProvider,
17
+ provider,
18
+ size = 24,
19
+ }: {
20
+ apiProvider: ApiProvider;
21
+ provider?: string;
22
+ size?: number;
23
+ }) => {
24
+ switch (apiProvider) {
25
+ case "open_ai":
26
+ return <IconBrandOpenai size={size} />;
27
+ case "aws_bedrock":
28
+ switch (provider?.toLowerCase()) {
29
+ case "amazon":
30
+ return <IconBrandAws size={size} />;
31
+ case "anthropic":
32
+ return <IconAi size={size} />;
33
+ case "mistral ai":
34
+ return <IconBrandMedium size={size} />;
35
+ case "meta":
36
+ return <IconBrandMeta size={size} />;
37
+
38
+ default:
39
+ return <IconBrandAws size={size} />;
40
+ }
41
+ case "yandex_fm":
42
+ return <IconBrandYandex size={size} />;
43
+ case "google_vertex_ai":
44
+ return <IconBrandGoogle size={size} />;
45
+
46
+ default:
47
+ return <IconMessageChatbot size={size} />;
48
+ }
49
+ };
@@ -0,0 +1 @@
1
+ export * from "./ProviderIcon";
@@ -0,0 +1,3 @@
1
+ export * from "./chat";
2
+ export * from "./modal";
3
+ export * from "./icons";
@@ -0,0 +1,97 @@
1
+ import React, { useEffect, useCallback } from "react";
2
+ import { Image, Text, Group, Stack, ActionIcon, Modal, Tooltip } from "@mantine/core";
3
+ import { useDisclosure } from "@mantine/hooks";
4
+ import { useNavigate } from "react-router-dom";
5
+ import { IconExternalLink } from "@tabler/icons-react";
6
+ import { ok } from "@/lib/assert";
7
+
8
+ interface IProps {
9
+ fileName: string;
10
+ fileUrl: string;
11
+ mimeType?: string;
12
+ createdAt?: string;
13
+ sourceUrl?: string;
14
+ sourceTitle?: string;
15
+ onClose: () => void;
16
+ }
17
+
18
+ export const ImagePopup: React.FC<IProps> = ({
19
+ fileName,
20
+ fileUrl,
21
+ mimeType,
22
+ createdAt,
23
+ sourceUrl,
24
+ sourceTitle,
25
+ onClose,
26
+ }) => {
27
+ const navigate = useNavigate();
28
+ const [opened, { open, close }] = useDisclosure(false);
29
+
30
+ useEffect(() => {
31
+ if (fileUrl) {
32
+ open();
33
+ }
34
+ }, [fileUrl, open]);
35
+
36
+ const handleClose = useCallback(() => {
37
+ onClose();
38
+ close();
39
+ }, [onClose, close]);
40
+
41
+ const navigateToChat = useCallback(() => {
42
+ ok(sourceUrl);
43
+ navigate(sourceUrl);
44
+ handleClose();
45
+ }, [navigate, sourceUrl, handleClose]);
46
+
47
+ const formatDate = (dateString: string) => {
48
+ return new Date(dateString).toLocaleDateString("en-US", {
49
+ year: "numeric",
50
+ month: "short",
51
+ day: "numeric",
52
+ hour: "2-digit",
53
+ minute: "2-digit",
54
+ });
55
+ };
56
+
57
+ // Image Preview Modal
58
+ return (
59
+ <Modal opened={opened} onClose={handleClose} size="xl" title="Image Preview" centered>
60
+ {fileUrl && (
61
+ <Stack gap="md">
62
+ <Image src={fileUrl} alt={fileName} fit="contain" mah="70vh" />
63
+
64
+ <Group justify="space-between">
65
+ <div>
66
+ <Text size="sm" fw={500}>
67
+ {fileName}
68
+ </Text>
69
+ <Text size="xs" c="dimmed">
70
+ {createdAt ? formatDate(createdAt) + " •" : ""} {mimeType}
71
+ </Text>
72
+ </div>
73
+
74
+ {sourceUrl && (
75
+ <Group gap="xs">
76
+ <Tooltip label="Open source">
77
+ <ActionIcon variant="light" onClick={navigateToChat}>
78
+ <IconExternalLink size={16} />
79
+ </ActionIcon>
80
+ </Tooltip>
81
+ </Group>
82
+ )}
83
+ </Group>
84
+
85
+ {sourceUrl && (
86
+ <Text size="sm" c="dimmed">
87
+ From:{" "}
88
+ <Text span c="blue" style={{ cursor: "pointer" }} onClick={navigateToChat}>
89
+ {sourceTitle || sourceUrl}
90
+ </Text>
91
+ </Text>
92
+ )}
93
+ </Stack>
94
+ )}
95
+ </Modal>
96
+ );
97
+ };
@@ -0,0 +1 @@
1
+ export * from "./ImagePopup";
@@ -0,0 +1,15 @@
1
+ .dropzone {
2
+ cursor: pointer;
3
+ &:hover {
4
+ color: var(--mantine-color-brand-6);
5
+ }
6
+ }
7
+
8
+ .dragging {
9
+ color: var(--mantine-color-brand-6);
10
+ }
11
+
12
+ .disabled {
13
+ color: var(--mantine-color-gray-4);
14
+ cursor: not-allowed;
15
+ }
@@ -0,0 +1,120 @@
1
+ import React, { useState, useCallback, useRef, useEffect } from "react";
2
+ import { Group, Tooltip, Box } from "@mantine/core";
3
+ import { IconFileUpload } from "@tabler/icons-react";
4
+ import { notEmpty } from "@/lib/assert";
5
+ import classes from "./FileDropzone.module.scss";
6
+
7
+ interface IProps {
8
+ disabled?: boolean;
9
+ uploadFormats?: string[];
10
+ onFilesAdd: (images: File[]) => void;
11
+ }
12
+
13
+ export const IMAGE_UPLOAD_FORMATS = ["image/jpeg", "image/png", "image/webp"];
14
+
15
+ export const FileDropzone: React.FC<IProps> = ({ onFilesAdd, disabled, uploadFormats = IMAGE_UPLOAD_FORMATS }) => {
16
+ const [isDragging, setIsDragging] = useState(false);
17
+ const dropzoneRef = useRef<HTMLDivElement>(null);
18
+ const fileInputRef = useRef<HTMLInputElement>(null);
19
+
20
+ // Handle paste events for clipboard images
21
+ useEffect(() => {
22
+ const handlePaste = (e: ClipboardEvent) => {
23
+ if (disabled) return;
24
+ if (e.clipboardData && e.clipboardData.items) {
25
+ const files = Array.from(e.clipboardData.items)
26
+ .map(f => f.getAsFile())
27
+ .filter(notEmpty);
28
+
29
+ if (files.length > 0) {
30
+ e.preventDefault();
31
+ e.stopPropagation();
32
+ onFilesAdd(files);
33
+ }
34
+ }
35
+ };
36
+
37
+ document.addEventListener("paste", handlePaste);
38
+ return () => {
39
+ document.removeEventListener("paste", handlePaste);
40
+ };
41
+ }, [onFilesAdd, disabled]);
42
+
43
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
44
+ e.preventDefault();
45
+ e.stopPropagation();
46
+ setIsDragging(true);
47
+ }, []);
48
+
49
+ const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
50
+ e.preventDefault();
51
+ e.stopPropagation();
52
+ setIsDragging(false);
53
+ }, []);
54
+
55
+ const handleDrop = useCallback(
56
+ (e: React.DragEvent<HTMLDivElement>) => {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ setIsDragging(false);
60
+ if (disabled) return;
61
+
62
+ const files =
63
+ e.dataTransfer.files && e.dataTransfer.files.length > 0
64
+ ? Array.from(e.dataTransfer.files).filter(f => f.size > 0)
65
+ : [];
66
+
67
+ onFilesAdd(files);
68
+ },
69
+ [onFilesAdd]
70
+ );
71
+
72
+ const handleFileSelect = useCallback(
73
+ (e: React.ChangeEvent<HTMLInputElement>) => {
74
+ if (e.target.files && e.target.files.length > 0) {
75
+ const files = Array.from(e.target.files).filter(f => f.size > 0);
76
+ if (files.length) {
77
+ onFilesAdd(files);
78
+ }
79
+ if (fileInputRef.current) {
80
+ fileInputRef.current.value = ""; // Reset file input value
81
+ }
82
+ }
83
+ },
84
+ [onFilesAdd]
85
+ );
86
+
87
+ const openFileDialog = () => {
88
+ if (disabled) return;
89
+ fileInputRef.current?.click();
90
+ };
91
+
92
+ return (
93
+ <>
94
+ <Box
95
+ ref={dropzoneRef}
96
+ className={`drop-zone ${classes.dropzone} ${isDragging ? classes.dragging : ""} ${disabled ? classes.disabled : ""}`}
97
+ onDragOver={handleDragOver}
98
+ onDragLeave={handleDragLeave}
99
+ onDrop={handleDrop}
100
+ onClick={openFileDialog}
101
+ >
102
+ <Group justify="center" gap="md" className="drop-zone-control">
103
+ <Tooltip label="Click or drop an image/document here" position="top">
104
+ <IconFileUpload size={32} stroke={1.5} />
105
+ </Tooltip>
106
+ <input
107
+ ref={fileInputRef}
108
+ type="file"
109
+ multiple
110
+ accept={uploadFormats?.join(",")}
111
+ onChange={handleFileSelect}
112
+ className={classes.fileInput}
113
+ style={{ display: "none" }}
114
+ disabled={disabled}
115
+ />
116
+ </Group>
117
+ </Box>
118
+ </>
119
+ );
120
+ };
@@ -0,0 +1 @@
1
+ export * from "./FileDropzone/FileDropzone";
package/src/core/ai.ts ADDED
@@ -0,0 +1 @@
1
+ export type ApiProvider = "AWS_BEDROCK" | "OPEN_AI" | "YANDEX_FM" | "GOOGLE_VERTEX_AI" | "DEEPSEEK" | "ANTHROPIC";
@@ -0,0 +1,4 @@
1
+ export * from "./message";
2
+ export * from "./model";
3
+ export * from "./user";
4
+ export * from "./ai";
@@ -0,0 +1,59 @@
1
+ import { User } from "./user";
2
+
3
+ export enum MessageType {
4
+ MESSAGE = "message",
5
+ SYSTEM = "system",
6
+ }
7
+
8
+ export enum MessageRole {
9
+ USER = "user",
10
+ ASSISTANT = "assistant",
11
+ ERROR = "error",
12
+ SYSTEM = "system",
13
+ }
14
+
15
+ export enum ResponseStatus {
16
+ IN_PROGRESS = "in_progress",
17
+ RAG_SEARCH = "rag_search",
18
+ WEB_SEARCH = "web_search",
19
+ CODE_INTERPRETER = "code_interpreter",
20
+ TOOL_CALL = "tool_call",
21
+ TOOL_CALL_COMPLETED = "tool_call_completed",
22
+ REASONING = "reasoning",
23
+ COMPLETED = "completed",
24
+ ERROR = "error",
25
+ }
26
+
27
+ export interface Message<TUser = User, TMetadata = Record<string, unknown>> {
28
+ id: string;
29
+ chatId: string;
30
+ content: string;
31
+ html?: string[];
32
+ role: MessageRole;
33
+ modelId?: string;
34
+ modelName?: string;
35
+ user?: TUser;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ streaming?: boolean;
39
+ linkedToMessageId?: string;
40
+ linkedMessages?: Message<TUser, TMetadata>[];
41
+ metadata?: TMetadata;
42
+ status?: ResponseStatus;
43
+ statusInfo?: string;
44
+ }
45
+
46
+ export interface PluginProps<TMessage = Message> {
47
+ message: TMessage;
48
+ disabled?: boolean;
49
+ onAddMessage?: (message: TMessage) => void;
50
+ onAction?: (messageId: string) => void;
51
+ onActionEnd?: (messageId: string) => void;
52
+ onMessageDeleted?: (args: { messagesToDelete?: TMessage[]; deleteAfter?: TMessage }) => void;
53
+ }
54
+
55
+ export interface ImageInput {
56
+ fileName: string;
57
+ mimeType: string;
58
+ bytesBase64: string;
59
+ }
@@ -0,0 +1,23 @@
1
+ import { ApiProvider } from "./ai";
2
+ import { User } from "./user";
3
+
4
+ export enum ModelType {
5
+ CHAT = "CHAT",
6
+ EMBEDDING = "EMBEDDING",
7
+ IMAGE_GENERATION = "IMAGE_GENERATION",
8
+ AUDIO_GENERATION = "AUDIO_GENERATION",
9
+ OTHER = "OTHER",
10
+ }
11
+
12
+ export interface Model<TUser = User> {
13
+ id: string;
14
+ name: string;
15
+ modelId: string;
16
+ apiProvider: ApiProvider;
17
+ type: ModelType;
18
+ provider: string;
19
+ isActive: boolean;
20
+ imageInput?: boolean;
21
+ maxInputTokens?: number;
22
+ user?: TUser;
23
+ }
@@ -0,0 +1,8 @@
1
+ export interface User {
2
+ id: string;
3
+ email: string;
4
+ firstName: string;
5
+ lastName: string;
6
+ avatarUrl?: string;
7
+ defaultModelId?: string;
8
+ }
@@ -0,0 +1,2 @@
1
+ export { useIntersectionObserver } from "./useIntersectionObserver";
2
+ export * from "./useTheme";
@@ -0,0 +1,24 @@
1
+ import { DependencyList, useEffect, useRef } from "react";
2
+
3
+ export function useIntersectionObserver<T extends HTMLElement>(callback: () => void, deps: DependencyList, delay = 0) {
4
+ const observer = useRef<IntersectionObserver | null>(null);
5
+ const ref = useRef<T>(null);
6
+
7
+ useEffect(() => {
8
+ observer.current?.disconnect();
9
+ const timeoutId = setTimeout(() => {
10
+ observer.current = new IntersectionObserver(entries => {
11
+ if (entries[0].isIntersecting) callback();
12
+ });
13
+
14
+ if (ref.current) observer.current.observe(ref.current);
15
+ }, delay);
16
+
17
+ return () => {
18
+ clearTimeout(timeoutId);
19
+ observer.current?.disconnect();
20
+ };
21
+ }, [deps, callback]);
22
+
23
+ return ref;
24
+ }
@@ -0,0 +1,66 @@
1
+ import React, { createContext, useContext, useEffect } from "react";
2
+ import { useLocalStorage } from "@mantine/hooks";
3
+
4
+ type ColorScheme = "light" | "dark" | "auto";
5
+
6
+ // Define the ThemeContext type
7
+ interface ThemeContextType {
8
+ colorScheme: ColorScheme;
9
+ setColorScheme: (value: ColorScheme) => void;
10
+ toggleColorScheme: () => void;
11
+ }
12
+
13
+ // Create the context
14
+ export const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
15
+
16
+ // Theme provider component
17
+ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
18
+ // Use localStorage to store theme preference
19
+ const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
20
+ key: "ui-theme",
21
+ defaultValue: "light",
22
+ });
23
+
24
+ // Apply theme changes to document
25
+ useEffect(() => {
26
+ if (colorScheme === "auto") {
27
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
28
+ document.documentElement.dataset.mantine = prefersDark ? "dark" : "light";
29
+ } else {
30
+ document.documentElement.dataset.mantine = colorScheme;
31
+ }
32
+ }, [colorScheme]);
33
+
34
+ // Toggle between light and dark themes
35
+ const toggleColorScheme = () => {
36
+ const newColorScheme = colorScheme === "dark" ? "light" : "dark";
37
+ setColorScheme(newColorScheme);
38
+ document.documentElement.dataset.mantine = newColorScheme;
39
+ };
40
+
41
+ // Listen for system color scheme changes if set to 'auto'
42
+ useEffect(() => {
43
+ if (colorScheme === "auto") {
44
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
45
+ const handleChange = (e: MediaQueryListEvent) => {
46
+ document.documentElement.dataset.mantine = e.matches ? "dark" : "light";
47
+ };
48
+
49
+ mediaQuery.addEventListener("change", handleChange);
50
+ return () => mediaQuery.removeEventListener("change", handleChange);
51
+ }
52
+ }, [colorScheme]);
53
+
54
+ return (
55
+ <ThemeContext.Provider value={{ colorScheme, setColorScheme, toggleColorScheme }}>{children}</ThemeContext.Provider>
56
+ );
57
+ };
58
+
59
+ // Custom hook to use the theme context
60
+ export const useTheme = (): ThemeContextType => {
61
+ const context = useContext(ThemeContext);
62
+ if (!context) {
63
+ throw new Error("useTheme must be used within a ThemeProvider");
64
+ }
65
+ return context;
66
+ };
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./core";
2
+ export * from "./components";
3
+ export * from "./controls";
4
+ export * from "./hooks";
5
+ export * from "./lib/markdown.parser";