@rimori/react-client 0.1.1 → 0.2.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.
- package/README.md +142 -1
- package/dist/components/Spinner.d.ts +0 -7
- package/dist/components/Spinner.js +1 -4
- package/dist/components/ai/Avatar.d.ts +1 -2
- package/dist/components/ai/Avatar.js +5 -3
- package/dist/components/ai/EmbeddedAssistent/AudioInputField.js +1 -1
- package/dist/components/ai/EmbeddedAssistent/VoiceRecorder.d.ts +11 -0
- package/dist/components/ai/EmbeddedAssistent/VoiceRecorder.js +95 -0
- package/dist/components/audio/Playbutton.js +3 -1
- package/dist/hooks/ThemeSetter.d.ts +2 -0
- package/dist/hooks/ThemeSetter.js +31 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -4
- package/dist/plugin/ThemeSetter.d.ts +1 -1
- package/dist/plugin/ThemeSetter.js +19 -7
- package/dist/providers/PluginProvider.js +3 -1
- package/package.json +4 -7
- package/src/components/ai/Avatar.tsx +9 -4
- package/src/components/ai/EmbeddedAssistent/AudioInputField.tsx +1 -1
- package/src/components/audio/Playbutton.tsx +28 -1
- package/src/hooks/ThemeSetter.ts +40 -0
- package/src/index.ts +3 -4
- package/src/providers/PluginProvider.tsx +4 -1
- package/src/components/MarkdownEditor.tsx +0 -144
- package/src/components/Spinner.tsx +0 -29
- package/src/plugin/ThemeSetter.ts +0 -23
- package/src/utils/FullscreenUtils.ts +0 -22
- /package/src/components/ai/EmbeddedAssistent/{VoiceRecoder.tsx → VoiceRecorder.tsx} +0 -0
package/README.md
CHANGED
|
@@ -1 +1,142 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Rimori React Client
|
|
2
|
+
|
|
3
|
+
The `@rimori/react-client` package contains the React bindings for the Rimori plugin runtime. Wrap your plugin UI with the provided context, use the hooks to talk to the Rimori platform, and drop in prebuilt assistant components without writing your own wiring.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [Overview](#overview)
|
|
7
|
+
- [Installation](#installation)
|
|
8
|
+
- [When to Use](#when-to-use)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [Hooks](#hooks)
|
|
11
|
+
- [Components](#components)
|
|
12
|
+
- [Styling](#styling)
|
|
13
|
+
- [Standalone Development](#standalone-development)
|
|
14
|
+
- [Additional Exports](#additional-exports)
|
|
15
|
+
- [Example](#example)
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
`@rimori/react-client` builds on top of `@rimori/client` and provides:
|
|
20
|
+
- `PluginProvider` that initializes the Rimori runtime, sets up the event bus, and injects the Rimori context into React.
|
|
21
|
+
- React hooks for AI chat, translation, and direct access to the Rimori client instance.
|
|
22
|
+
- Prebuilt components for voice-enabled assistants, avatars, audio playback, and the Rimori context menu.
|
|
23
|
+
- Automatic handling of theme propagation, standalone authentication, and URL tracking inside the Rimori shell.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @rimori/react-client @rimori/client react react-dom
|
|
29
|
+
# or
|
|
30
|
+
yarn add @rimori/react-client @rimori/client react react-dom
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both React 18 and `@rimori/client` are peer dependencies. Keep them in sync with the versions used by the Rimori platform.
|
|
34
|
+
|
|
35
|
+
## When to Use
|
|
36
|
+
|
|
37
|
+
Choose this package whenever your plugin UI is written in React and you need:
|
|
38
|
+
- Access to the `RimoriClient` through idiomatic hooks (`useRimori`).
|
|
39
|
+
- Streamed AI chat experiences without managing the event bus yourself.
|
|
40
|
+
- Drop-in UI for the Rimori assistant, avatars, contextual menus, and audio controls.
|
|
41
|
+
- A translation helper that respects the Rimori i18next pipeline.
|
|
42
|
+
|
|
43
|
+
If you need to interact with the Rimori platform outside of React (workers, CLI, background scripts), keep using `@rimori/client` directly.
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import "@rimori/react-client/dist/style.css";
|
|
49
|
+
import { PluginProvider, useRimori, useChat, useTranslation } from "@rimori/react-client";
|
|
50
|
+
|
|
51
|
+
function Dashboard() {
|
|
52
|
+
const client = useRimori();
|
|
53
|
+
const { t } = useTranslation();
|
|
54
|
+
const { messages, append, isLoading } = useChat();
|
|
55
|
+
|
|
56
|
+
const send = () => {
|
|
57
|
+
append([{ role: "user", content: t("discussion.prompts.askForHelp") }]);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div>
|
|
62
|
+
<h1>{t("discussion.title")}</h1>
|
|
63
|
+
<button onClick={send} disabled={isLoading}>
|
|
64
|
+
{t("common.buttons.getStarted")}
|
|
65
|
+
</button>
|
|
66
|
+
<pre>{JSON.stringify(messages, null, 2)}</pre>
|
|
67
|
+
<p>{client.plugin.pluginId}</p>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function App() {
|
|
73
|
+
return (
|
|
74
|
+
<PluginProvider pluginId="your-plugin-id">
|
|
75
|
+
<Dashboard />
|
|
76
|
+
</PluginProvider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Hooks
|
|
82
|
+
|
|
83
|
+
- `useRimori()` – returns the underlying `RimoriClient` instance (database, AI, community, event bus, translator access, etc.). Throws if used outside `PluginProvider`.
|
|
84
|
+
- `useChat(tools?)` – manages streaming conversations with the Rimori AI. Provides `messages`, `append`, `isLoading`, `setMessages`, and `lastMessage`.
|
|
85
|
+
- `useTranslation()` – returns `{ t, ready }` where `t` is a safe translator bound to the current user language. Use it for every user-visible string, following the Rimori translation key conventions.
|
|
86
|
+
|
|
87
|
+
## Components
|
|
88
|
+
|
|
89
|
+
All components require being rendered inside `PluginProvider` so they can reach the shared client instance.
|
|
90
|
+
|
|
91
|
+
- `Assistant` – full chat UI shell with streaming responses and optional tool invocation support.
|
|
92
|
+
- `Avatar` – status-aware avatar component that reflects assistant state.
|
|
93
|
+
- `EmbeddedAssistent` utilities – granular building blocks (`CircleAudioAvatar`, `VoiceRecorder`, TTS `Player`, etc.) for composing your own assistant flows.
|
|
94
|
+
- `PlayButton` – accessible audio playback button that integrates with Rimori's audio controller.
|
|
95
|
+
- `ContextMenu` – automatically injected by `PluginProvider` (can be disabled via `settings.disableContextMenu`).
|
|
96
|
+
|
|
97
|
+
## Styling
|
|
98
|
+
|
|
99
|
+
Import the generated stylesheet once in your app entry point:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import "@rimori/react-client/dist/style.css";
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The stylesheet contains base styles for the assistant components and ensures they respond to the Rimori theme tokens that are passed through the sandbox handshake.
|
|
106
|
+
|
|
107
|
+
## Standalone Development
|
|
108
|
+
|
|
109
|
+
`PluginProvider` detects when the plugin is not hosted inside Rimori and automatically spins up `StandaloneClient`. During local development it shows a login overlay that lets you authenticate with your Rimori developer account before establishing the runtime connection.
|
|
110
|
+
|
|
111
|
+
## Additional Exports
|
|
112
|
+
|
|
113
|
+
- `FirstMessages` – helper preset with common greeting messages for assistant onboarding flows.
|
|
114
|
+
|
|
115
|
+
## Example
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
import { PluginProvider, Assistant, useRimori } from "@rimori/react-client";
|
|
119
|
+
|
|
120
|
+
function AssistantPanel() {
|
|
121
|
+
const client = useRimori();
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="assistant-panel">
|
|
125
|
+
<Assistant
|
|
126
|
+
placeholderKey="discussion.input.placeholder"
|
|
127
|
+
onMessage={(message) => client.event.emit("self.discussion.onAssistantMessage", { message })}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function Root() {
|
|
134
|
+
return (
|
|
135
|
+
<PluginProvider pluginId="your-plugin-id">
|
|
136
|
+
<AssistantPanel />
|
|
137
|
+
</PluginProvider>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Pair this React package with the updated `@rimori/client` README to understand every controller that is available on the returned `RimoriClient` instance.
|
|
@@ -1,4 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export const Spinner = ({ text, className, size = '30px' }) => {
|
|
3
|
-
return (_jsxs("div", { className: 'flex items-center space-x-2 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1 mr-3 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), text && _jsx("span", { className: "", children: text })] }));
|
|
4
|
-
};
|
|
1
|
+
export {};
|
|
@@ -5,10 +5,9 @@ interface Props {
|
|
|
5
5
|
agentTools: Tool[];
|
|
6
6
|
avatarImageUrl: string;
|
|
7
7
|
circleSize?: string;
|
|
8
|
-
isDarkTheme?: boolean;
|
|
9
8
|
children?: React.ReactNode;
|
|
10
9
|
autoStartConversation?: FirstMessages;
|
|
11
10
|
className?: string;
|
|
12
11
|
}
|
|
13
|
-
export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children,
|
|
12
|
+
export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize, className, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
14
13
|
export {};
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
-
import { VoiceRecorder } from './EmbeddedAssistent/
|
|
3
|
+
import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
|
|
4
4
|
import { MessageSender } from './EmbeddedAssistent/TTS/MessageSender';
|
|
5
5
|
import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
|
|
6
6
|
import { useChat } from '../../hooks/UseChatHook';
|
|
7
7
|
import { useRimori } from '../../providers/PluginProvider';
|
|
8
8
|
import { getFirstMessages } from './utils';
|
|
9
|
-
|
|
9
|
+
import { isDarkTheme } from '../../hooks/ThemeSetter';
|
|
10
|
+
export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize = '300px', className, }) {
|
|
10
11
|
const { ai, event } = useRimori();
|
|
11
12
|
const [agentReplying, setAgentReplying] = useState(false);
|
|
12
13
|
const [isProcessingMessage, setIsProcessingMessage] = useState(false);
|
|
13
14
|
const sender = useMemo(() => new MessageSender(ai.getVoice, voiceId), [voiceId]);
|
|
14
15
|
const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
|
|
16
|
+
const isDarkThemeValue = useMemo(() => isDarkTheme(), []);
|
|
15
17
|
useEffect(() => {
|
|
16
18
|
console.log('messages', messages);
|
|
17
19
|
}, [messages]);
|
|
@@ -46,7 +48,7 @@ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversat
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
}, [lastMessage, isLoading]);
|
|
49
|
-
return (_jsxs("div", { className: `md:pb-8 ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, className: "mx-auto", imageUrl: avatarImageUrl, isDarkTheme:
|
|
51
|
+
return (_jsxs("div", { className: `md:pb-8 ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, className: "mx-auto", imageUrl: avatarImageUrl, isDarkTheme: isDarkThemeValue }), children, _jsx(VoiceRecorder, { iconSize: "30", className: "w-16 h-16 shadow-lg rounded-full bg-gray-400 dark:bg-gray-800", disabled: agentReplying, loading: isProcessingMessage, enablePushToTalk: true, onVoiceRecorded: (message) => {
|
|
50
52
|
setAgentReplying(true);
|
|
51
53
|
append([
|
|
52
54
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
-
import { VoiceRecorder } from './
|
|
3
|
+
import { VoiceRecorder } from './VoiceRecorder';
|
|
4
4
|
import { BiSolidRightArrow } from 'react-icons/bi';
|
|
5
5
|
import { HiMiniSpeakerXMark, HiMiniSpeakerWave } from 'react-icons/hi2';
|
|
6
6
|
export function AudioInputField({ onSubmit, onAudioControl, blockSubmission = false }) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
iconSize?: string;
|
|
3
|
+
className?: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
loading?: boolean;
|
|
6
|
+
enablePushToTalk?: boolean;
|
|
7
|
+
onRecordingStatusChange: (running: boolean) => void;
|
|
8
|
+
onVoiceRecorded: (message: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare const VoiceRecorder: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<unknown>>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
11
|
+
import { useRimori } from '../../../providers/PluginProvider';
|
|
12
|
+
import { FaMicrophone, FaSpinner } from 'react-icons/fa6';
|
|
13
|
+
import { AudioController } from '@rimori/client';
|
|
14
|
+
import { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
|
|
15
|
+
export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className, disabled, loading, onRecordingStatusChange, enablePushToTalk = false, }, ref) => {
|
|
16
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
17
|
+
const [internalIsProcessing, setInternalIsProcessing] = useState(false);
|
|
18
|
+
const audioControllerRef = useRef(null);
|
|
19
|
+
const { ai, plugin } = useRimori();
|
|
20
|
+
// Ref for latest onVoiceRecorded callback
|
|
21
|
+
const onVoiceRecordedRef = useRef(onVoiceRecorded);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
onVoiceRecordedRef.current = onVoiceRecorded;
|
|
24
|
+
}, [onVoiceRecorded]);
|
|
25
|
+
const startRecording = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
26
|
+
try {
|
|
27
|
+
if (!audioControllerRef.current) {
|
|
28
|
+
audioControllerRef.current = new AudioController(plugin.pluginId);
|
|
29
|
+
}
|
|
30
|
+
yield audioControllerRef.current.startRecording();
|
|
31
|
+
setIsRecording(true);
|
|
32
|
+
onRecordingStatusChange(true);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error('Failed to start recording:', error);
|
|
36
|
+
// Handle permission denied or other errors
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
const stopRecording = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
40
|
+
try {
|
|
41
|
+
if (audioControllerRef.current && isRecording) {
|
|
42
|
+
const audioResult = yield audioControllerRef.current.stopRecording();
|
|
43
|
+
// console.log("audioResult: ", audioResult);
|
|
44
|
+
setInternalIsProcessing(true);
|
|
45
|
+
// Play the recorded audio from the Blob
|
|
46
|
+
// const blobUrl = URL.createObjectURL(audioResult.recording);
|
|
47
|
+
// const audioRef = new Audio(blobUrl);
|
|
48
|
+
// audioRef.onended = () => URL.revokeObjectURL(blobUrl);
|
|
49
|
+
// audioRef.play().catch((e) => console.error('Playback error:', e));
|
|
50
|
+
// console.log("audioBlob: ", audioResult.recording);
|
|
51
|
+
const text = yield ai.getTextFromVoice(audioResult.recording);
|
|
52
|
+
// console.log("stt result", text);
|
|
53
|
+
// throw new Error("test");
|
|
54
|
+
setInternalIsProcessing(false);
|
|
55
|
+
onVoiceRecordedRef.current(text);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
console.error('Failed to stop recording:', error);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
setIsRecording(false);
|
|
63
|
+
onRecordingStatusChange(false);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
useImperativeHandle(ref, () => ({
|
|
67
|
+
startRecording,
|
|
68
|
+
stopRecording,
|
|
69
|
+
}));
|
|
70
|
+
// push to talk feature
|
|
71
|
+
const spacePressedRef = useRef(false);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!enablePushToTalk)
|
|
74
|
+
return;
|
|
75
|
+
const handleKeyDown = (event) => __awaiter(void 0, void 0, void 0, function* () {
|
|
76
|
+
if (event.code === 'Space' && !spacePressedRef.current) {
|
|
77
|
+
spacePressedRef.current = true;
|
|
78
|
+
yield startRecording();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const handleKeyUp = (event) => {
|
|
82
|
+
if (event.code === 'Space' && spacePressedRef.current) {
|
|
83
|
+
spacePressedRef.current = false;
|
|
84
|
+
stopRecording();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
88
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
91
|
+
window.removeEventListener('keyup', handleKeyUp);
|
|
92
|
+
};
|
|
93
|
+
}, [enablePushToTalk]);
|
|
94
|
+
return (_jsx("button", { className: 'flex flex-row justify-center items-center rounded-full mx-auto disabled:opacity-50 ' + className, onClick: isRecording ? stopRecording : startRecording, disabled: disabled || loading || internalIsProcessing, children: loading || internalIsProcessing ? (_jsx(FaSpinner, { className: "animate-spin" })) : (_jsx(FaMicrophone, { size: iconSize, className: isRecording ? 'text-red-600' : '' })) }));
|
|
95
|
+
});
|
|
@@ -11,7 +11,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
import { useState, useEffect } from 'react';
|
|
12
12
|
import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
|
|
13
13
|
import { useRimori } from '../../providers/PluginProvider';
|
|
14
|
-
import { Spinner } from '../Spinner';
|
|
15
14
|
import { EventBus } from '@rimori/client';
|
|
16
15
|
export const AudioPlayOptions = [0.8, 0.9, 1.0, 1.1, 1.2, 1.5];
|
|
17
16
|
let isFetchingAudio = false;
|
|
@@ -80,3 +79,6 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
|
|
|
80
79
|
}, [playOnMount]);
|
|
81
80
|
return (_jsx("div", { className: "group relative", children: _jsxs("div", { className: "flex flex-row items-end", children: [!hide && (_jsx("button", { className: "text-gray-500", onClick: togglePlayback, disabled: isLoading, children: isLoading ? _jsx(Spinner, {}) : isPlaying ? _jsx(FaStopCircle, { size: '25px' }) : _jsx(FaPlayCircle, { size: '25px' }) })), enableSpeedAdjustment && (_jsxs("div", { className: "ml-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-row text-sm text-gray-500", children: [_jsx("span", { className: "pr-1", children: "Speed: " }), _jsx("select", { value: speed, className: "appearance-none cursor-pointer pr-0 p-0 rounded shadow leading-tight focus:outline-none focus:bg-gray-800 focus:ring bg-transparent border-0", onChange: (e) => setSpeed(parseFloat(e.target.value)), disabled: isLoading, children: AudioPlayOptions.map((s) => (_jsx("option", { value: s, children: s }, s))) })] }))] }) }));
|
|
82
81
|
};
|
|
82
|
+
const Spinner = ({ text, className, size = '30px' }) => {
|
|
83
|
+
return (_jsxs("div", { className: 'flex items-center space-x-2 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1 mr-3 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), text && _jsx("span", { className: "", children: text })] }));
|
|
84
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useTheme(theme) {
|
|
3
|
+
const [isDark, setIsDark] = useState(false);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const root = document.documentElement;
|
|
6
|
+
const nextIsDark = isDarkTheme(theme);
|
|
7
|
+
setIsDark(nextIsDark);
|
|
8
|
+
root.classList.add('dark:text-gray-200');
|
|
9
|
+
if (nextIsDark) {
|
|
10
|
+
root.setAttribute('data-theme', 'dark');
|
|
11
|
+
root.classList.add('dark', 'dark:bg-gray-950');
|
|
12
|
+
root.style.background = 'hsl(var(--background))';
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
root.removeAttribute('data-theme');
|
|
16
|
+
root.classList.remove('dark', 'dark:bg-gray-950');
|
|
17
|
+
root.style.background = '';
|
|
18
|
+
}, [theme]);
|
|
19
|
+
return isDark;
|
|
20
|
+
}
|
|
21
|
+
export function isDarkTheme(theme) {
|
|
22
|
+
// If no theme provided, try to get from URL as fallback (for standalone mode)
|
|
23
|
+
if (!theme) {
|
|
24
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
25
|
+
theme = urlParams.get('theme');
|
|
26
|
+
}
|
|
27
|
+
if (!theme || theme === 'system') {
|
|
28
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
29
|
+
}
|
|
30
|
+
return theme === 'dark';
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export * from './hooks/UseChatHook';
|
|
2
2
|
export * from './providers/PluginProvider';
|
|
3
|
-
export * from './utils/FullscreenUtils';
|
|
4
3
|
export * from './components/audio/Playbutton';
|
|
5
4
|
export * from './components/ai/EmbeddedAssistent/TTS/Player';
|
|
6
|
-
export * from './components/
|
|
7
|
-
export * from './components/MarkdownEditor';
|
|
8
|
-
export * from './components/Spinner';
|
|
5
|
+
export * from './components/ai/Avatar';
|
|
9
6
|
export { FirstMessages } from './components/ai/utils';
|
|
10
7
|
export { useTranslation } from './hooks/I18nHooks';
|
|
8
|
+
export { Avatar } from './components/ai/Avatar';
|
|
9
|
+
export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// Re-export everything
|
|
2
2
|
export * from './hooks/UseChatHook';
|
|
3
3
|
export * from './providers/PluginProvider';
|
|
4
|
-
export * from './utils/FullscreenUtils';
|
|
5
4
|
export * from './components/audio/Playbutton';
|
|
6
5
|
export * from './components/ai/EmbeddedAssistent/TTS/Player';
|
|
7
|
-
export * from './components/
|
|
8
|
-
export * from './components/MarkdownEditor';
|
|
9
|
-
export * from './components/Spinner';
|
|
6
|
+
export * from './components/ai/Avatar';
|
|
10
7
|
export { useTranslation } from './hooks/I18nHooks';
|
|
8
|
+
export { Avatar } from './components/ai/Avatar';
|
|
9
|
+
export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function useTheme(theme?: string | null): boolean;
|
|
2
2
|
export declare function isDarkTheme(theme?: string | null): boolean;
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
document.documentElement
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useTheme(theme) {
|
|
3
|
+
const [isDark, setIsDark] = useState(false);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const root = document.documentElement;
|
|
6
|
+
const nextIsDark = isDarkTheme(theme);
|
|
7
|
+
setIsDark(nextIsDark);
|
|
8
|
+
root.classList.add('dark:text-gray-200');
|
|
9
|
+
if (nextIsDark) {
|
|
10
|
+
root.setAttribute('data-theme', 'dark');
|
|
11
|
+
root.classList.add('dark', 'dark:bg-gray-950');
|
|
12
|
+
root.style.background = 'hsl(var(--background))';
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
root.removeAttribute('data-theme');
|
|
16
|
+
root.classList.remove('dark', 'dark:bg-gray-950');
|
|
17
|
+
root.style.background = '';
|
|
18
|
+
}, [theme]);
|
|
19
|
+
return isDark;
|
|
8
20
|
}
|
|
9
21
|
export function isDarkTheme(theme) {
|
|
10
22
|
// If no theme provided, try to get from URL as fallback (for standalone mode)
|
|
@@ -11,12 +11,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
import { createContext, useContext, useEffect, useState } from 'react';
|
|
12
12
|
import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
|
|
13
13
|
import ContextMenu from '../components/ContextMenu';
|
|
14
|
-
import {
|
|
14
|
+
import { useTheme } from '../hooks/ThemeSetter';
|
|
15
15
|
const PluginContext = createContext(null);
|
|
16
16
|
export const PluginProvider = ({ children, pluginId, settings }) => {
|
|
17
17
|
const [plugin, setPlugin] = useState(null);
|
|
18
18
|
const [standaloneClient, setStandaloneClient] = useState(false);
|
|
19
19
|
const [applicationMode, setApplicationMode] = useState(null);
|
|
20
|
+
const [theme, setTheme] = useState(null);
|
|
21
|
+
useTheme(theme);
|
|
20
22
|
const isSidebar = applicationMode === 'sidebar';
|
|
21
23
|
const isSettings = applicationMode === 'settings';
|
|
22
24
|
useEffect(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimori/react-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -20,18 +20,15 @@
|
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": "^18.0.0",
|
|
22
22
|
"react-dom": "^18.0.0",
|
|
23
|
-
"@rimori/client": "^2.
|
|
23
|
+
"@rimori/client": "^2.1.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@tiptap/react": "2.10.3",
|
|
27
|
-
"@tiptap/starter-kit": "2.10.3",
|
|
28
26
|
"html2canvas": "1.4.1",
|
|
29
|
-
"react-icons": "5.4.0"
|
|
30
|
-
"tiptap-markdown": "0.8.10"
|
|
27
|
+
"react-icons": "5.4.0"
|
|
31
28
|
},
|
|
32
29
|
"devDependencies": {
|
|
33
30
|
"@eslint/js": "^9.37.0",
|
|
34
|
-
"@rimori/client": "^2.
|
|
31
|
+
"@rimori/client": "^2.1.0",
|
|
35
32
|
"eslint-config-prettier": "^10.1.8",
|
|
36
33
|
"eslint-plugin-prettier": "^5.5.4",
|
|
37
34
|
"eslint-plugin-react-hooks": "^7.0.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { VoiceRecorder } from './EmbeddedAssistent/
|
|
2
|
+
import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
|
|
3
3
|
import { MessageSender } from './EmbeddedAssistent/TTS/MessageSender';
|
|
4
4
|
import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
|
|
5
5
|
import { Tool } from '@rimori/client';
|
|
@@ -7,13 +7,13 @@ import { useChat } from '../../hooks/UseChatHook';
|
|
|
7
7
|
import { useRimori } from '../../providers/PluginProvider';
|
|
8
8
|
import { getFirstMessages } from './utils';
|
|
9
9
|
import { FirstMessages } from './utils';
|
|
10
|
+
import { isDarkTheme } from '../../hooks/ThemeSetter';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
voiceId: string;
|
|
13
14
|
agentTools: Tool[];
|
|
14
15
|
avatarImageUrl: string;
|
|
15
16
|
circleSize?: string;
|
|
16
|
-
isDarkTheme?: boolean;
|
|
17
17
|
children?: React.ReactNode;
|
|
18
18
|
autoStartConversation?: FirstMessages;
|
|
19
19
|
className?: string;
|
|
@@ -25,7 +25,6 @@ export function Avatar({
|
|
|
25
25
|
agentTools,
|
|
26
26
|
autoStartConversation,
|
|
27
27
|
children,
|
|
28
|
-
isDarkTheme = false,
|
|
29
28
|
circleSize = '300px',
|
|
30
29
|
className,
|
|
31
30
|
}: Props) {
|
|
@@ -34,6 +33,7 @@ export function Avatar({
|
|
|
34
33
|
const [isProcessingMessage, setIsProcessingMessage] = useState(false);
|
|
35
34
|
const sender = useMemo(() => new MessageSender(ai.getVoice, voiceId), [voiceId]);
|
|
36
35
|
const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
|
|
36
|
+
const isDarkThemeValue = useMemo(() => isDarkTheme(), []);
|
|
37
37
|
|
|
38
38
|
useEffect(() => {
|
|
39
39
|
console.log('messages', messages);
|
|
@@ -74,7 +74,12 @@ export function Avatar({
|
|
|
74
74
|
|
|
75
75
|
return (
|
|
76
76
|
<div className={`md:pb-8 ${className || ''}`}>
|
|
77
|
-
<CircleAudioAvatar
|
|
77
|
+
<CircleAudioAvatar
|
|
78
|
+
width={circleSize}
|
|
79
|
+
className="mx-auto"
|
|
80
|
+
imageUrl={avatarImageUrl}
|
|
81
|
+
isDarkTheme={isDarkThemeValue}
|
|
82
|
+
/>
|
|
78
83
|
{children}
|
|
79
84
|
<VoiceRecorder
|
|
80
85
|
iconSize="30"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import { VoiceRecorder } from './
|
|
2
|
+
import { VoiceRecorder } from './VoiceRecorder';
|
|
3
3
|
import { BiSolidRightArrow } from 'react-icons/bi';
|
|
4
4
|
import { HiMiniSpeakerXMark, HiMiniSpeakerWave } from 'react-icons/hi2';
|
|
5
5
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
|
|
3
3
|
import { useRimori } from '../../providers/PluginProvider';
|
|
4
|
-
import { Spinner } from '../Spinner';
|
|
5
4
|
import { EventBus } from '@rimori/client';
|
|
6
5
|
|
|
7
6
|
type AudioPlayerProps = {
|
|
@@ -124,3 +123,31 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
|
|
124
123
|
</div>
|
|
125
124
|
);
|
|
126
125
|
};
|
|
126
|
+
|
|
127
|
+
interface SpinnerProps {
|
|
128
|
+
text?: string;
|
|
129
|
+
size?: string;
|
|
130
|
+
className?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const Spinner = ({ text, className, size = '30px' }: SpinnerProps) => {
|
|
134
|
+
return (
|
|
135
|
+
<div className={'flex items-center space-x-2 ' + className}>
|
|
136
|
+
<svg
|
|
137
|
+
style={{ width: size, height: size }}
|
|
138
|
+
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
139
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
140
|
+
fill="none"
|
|
141
|
+
viewBox="0 0 24 24"
|
|
142
|
+
>
|
|
143
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
144
|
+
<path
|
|
145
|
+
className="opacity-75"
|
|
146
|
+
fill="currentColor"
|
|
147
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
148
|
+
></path>
|
|
149
|
+
</svg>
|
|
150
|
+
{text && <span className="">{text}</span>}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useTheme(theme?: string | null): boolean {
|
|
4
|
+
const [isDark, setIsDark] = useState(false);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const root = document.documentElement;
|
|
8
|
+
const nextIsDark = isDarkTheme(theme);
|
|
9
|
+
|
|
10
|
+
setIsDark(nextIsDark);
|
|
11
|
+
root.classList.add('dark:text-gray-200');
|
|
12
|
+
|
|
13
|
+
if (nextIsDark) {
|
|
14
|
+
root.setAttribute('data-theme', 'dark');
|
|
15
|
+
root.classList.add('dark', 'dark:bg-gray-950');
|
|
16
|
+
root.style.background = 'hsl(var(--background))';
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
root.removeAttribute('data-theme');
|
|
21
|
+
root.classList.remove('dark', 'dark:bg-gray-950');
|
|
22
|
+
root.style.background = '';
|
|
23
|
+
}, [theme]);
|
|
24
|
+
|
|
25
|
+
return isDark;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isDarkTheme(theme?: string | null): boolean {
|
|
29
|
+
// If no theme provided, try to get from URL as fallback (for standalone mode)
|
|
30
|
+
if (!theme) {
|
|
31
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
32
|
+
theme = urlParams.get('theme');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!theme || theme === 'system') {
|
|
36
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return theme === 'dark';
|
|
40
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// Re-export everything
|
|
2
2
|
export * from './hooks/UseChatHook';
|
|
3
3
|
export * from './providers/PluginProvider';
|
|
4
|
-
export * from './utils/FullscreenUtils';
|
|
5
4
|
export * from './components/audio/Playbutton';
|
|
6
5
|
export * from './components/ai/EmbeddedAssistent/TTS/Player';
|
|
7
|
-
export * from './components/
|
|
8
|
-
export * from './components/MarkdownEditor';
|
|
9
|
-
export * from './components/Spinner';
|
|
6
|
+
export * from './components/ai/Avatar';
|
|
10
7
|
export { FirstMessages } from './components/ai/utils';
|
|
11
8
|
export { useTranslation } from './hooks/I18nHooks';
|
|
9
|
+
export { Avatar } from './components/ai/Avatar';
|
|
10
|
+
export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useContext, ReactNode, useEffect, useState } from 'react';
|
|
2
2
|
import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
|
|
3
3
|
import ContextMenu from '../components/ContextMenu';
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from '../hooks/ThemeSetter';
|
|
5
5
|
|
|
6
6
|
interface PluginProviderProps {
|
|
7
7
|
children: ReactNode;
|
|
@@ -17,6 +17,9 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
|
|
|
17
17
|
const [plugin, setPlugin] = useState<RimoriClient | null>(null);
|
|
18
18
|
const [standaloneClient, setStandaloneClient] = useState<StandaloneClient | boolean>(false);
|
|
19
19
|
const [applicationMode, setApplicationMode] = useState<string | null>(null);
|
|
20
|
+
const [theme, setTheme] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useTheme(theme);
|
|
20
23
|
|
|
21
24
|
const isSidebar = applicationMode === 'sidebar';
|
|
22
25
|
const isSettings = applicationMode === 'settings';
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { Markdown } from 'tiptap-markdown';
|
|
2
|
-
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
-
import { PiCodeBlock } from 'react-icons/pi';
|
|
4
|
-
import { TbBlockquote } from 'react-icons/tb';
|
|
5
|
-
import { GoListOrdered } from 'react-icons/go';
|
|
6
|
-
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
|
7
|
-
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
|
|
8
|
-
import { LuHeading1, LuHeading2, LuHeading3 } from 'react-icons/lu';
|
|
9
|
-
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
10
|
-
|
|
11
|
-
// This inplementation is rooted in the Tiptap editor basic example https://codesandbox.io/p/devbox/editor-9x9dkd
|
|
12
|
-
|
|
13
|
-
interface EditorButtonProps {
|
|
14
|
-
action: string;
|
|
15
|
-
isActive?: boolean;
|
|
16
|
-
label: string | React.ReactNode;
|
|
17
|
-
disabled?: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const EditorButton = ({ action, isActive, label, disabled }: EditorButtonProps) => {
|
|
21
|
-
const { editor } = useCurrentEditor() as any;
|
|
22
|
-
|
|
23
|
-
if (!editor) {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (action.includes('heading')) {
|
|
28
|
-
const level = parseInt(action[action.length - 1]);
|
|
29
|
-
return (
|
|
30
|
-
<button
|
|
31
|
-
onClick={() => editor.chain().focus().toggleHeading({ level: level }).run()}
|
|
32
|
-
className={`pl-2 ${isActive ? 'is-active' : ''}`}
|
|
33
|
-
>
|
|
34
|
-
{label}
|
|
35
|
-
</button>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<button
|
|
41
|
-
onClick={() => editor.chain().focus()[action]().run()}
|
|
42
|
-
disabled={disabled ? !editor.can().chain().focus()[action]().run() : false}
|
|
43
|
-
className={`pl-2 ${isActive ? 'is-active' : ''}`}
|
|
44
|
-
>
|
|
45
|
-
{label}
|
|
46
|
-
</button>
|
|
47
|
-
);
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const MenuBar = () => {
|
|
51
|
-
const { editor } = useCurrentEditor();
|
|
52
|
-
|
|
53
|
-
if (!editor) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div className="bg-gray-400 dark:bg-gray-800 dark:text-white text-lg flex flex-row flex-wrap items-center p-1">
|
|
59
|
-
<EditorButton action="toggleBold" isActive={editor.isActive('bold')} label={<FaBold />} disabled />
|
|
60
|
-
<EditorButton action="toggleItalic" isActive={editor.isActive('italic')} label={<FaItalic />} disabled />
|
|
61
|
-
<EditorButton action="toggleStrike" isActive={editor.isActive('strike')} label={<FaStrikethrough />} disabled />
|
|
62
|
-
<EditorButton action="toggleCode" isActive={editor.isActive('code')} label={<FaCode />} disabled />
|
|
63
|
-
<EditorButton action="setParagraph" isActive={editor.isActive('paragraph')} label={<FaParagraph />} />
|
|
64
|
-
<EditorButton
|
|
65
|
-
action="setHeading1"
|
|
66
|
-
isActive={editor.isActive('heading', { level: 1 })}
|
|
67
|
-
label={<LuHeading1 size={'24px'} />}
|
|
68
|
-
/>
|
|
69
|
-
<EditorButton
|
|
70
|
-
action="setHeading2"
|
|
71
|
-
isActive={editor.isActive('heading', { level: 2 })}
|
|
72
|
-
label={<LuHeading2 size={'24px'} />}
|
|
73
|
-
/>
|
|
74
|
-
<EditorButton
|
|
75
|
-
action="setHeading3"
|
|
76
|
-
isActive={editor.isActive('heading', { level: 3 })}
|
|
77
|
-
label={<LuHeading3 size={'24px'} />}
|
|
78
|
-
/>
|
|
79
|
-
<EditorButton
|
|
80
|
-
action="toggleBulletList"
|
|
81
|
-
isActive={editor.isActive('bulletList')}
|
|
82
|
-
label={<AiOutlineUnorderedList size={'24px'} />}
|
|
83
|
-
/>
|
|
84
|
-
<EditorButton
|
|
85
|
-
action="toggleOrderedList"
|
|
86
|
-
isActive={editor.isActive('orderedList')}
|
|
87
|
-
label={<GoListOrdered size={'24px'} />}
|
|
88
|
-
/>
|
|
89
|
-
<EditorButton
|
|
90
|
-
action="toggleCodeBlock"
|
|
91
|
-
isActive={editor.isActive('codeBlock')}
|
|
92
|
-
label={<PiCodeBlock size={'24px'} />}
|
|
93
|
-
/>
|
|
94
|
-
<EditorButton
|
|
95
|
-
action="toggleBlockquote"
|
|
96
|
-
isActive={editor.isActive('blockquote')}
|
|
97
|
-
label={<TbBlockquote size={'24px'} />}
|
|
98
|
-
/>
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const extensions = [
|
|
104
|
-
StarterKit.configure({
|
|
105
|
-
bulletList: {
|
|
106
|
-
HTMLAttributes: {
|
|
107
|
-
class: 'list-disc list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0',
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
orderedList: {
|
|
111
|
-
HTMLAttributes: {
|
|
112
|
-
className: 'list-decimal list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0',
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
}),
|
|
116
|
-
Markdown,
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
interface Props {
|
|
120
|
-
content?: string;
|
|
121
|
-
editable: boolean;
|
|
122
|
-
className?: string;
|
|
123
|
-
onUpdate?: (content: string) => void;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export const MarkdownEditor = (props: Props) => {
|
|
127
|
-
return (
|
|
128
|
-
<div
|
|
129
|
-
className={'text-md border border-gray-800 overflow-hidden ' + props.className}
|
|
130
|
-
style={{ borderWidth: props.editable ? 1 : 0 }}
|
|
131
|
-
>
|
|
132
|
-
<EditorProvider
|
|
133
|
-
key={(props.editable ? 'editable' : 'readonly') + props.content}
|
|
134
|
-
slotBefore={props.editable ? <MenuBar /> : null}
|
|
135
|
-
extensions={extensions}
|
|
136
|
-
content={props.content}
|
|
137
|
-
editable={props.editable}
|
|
138
|
-
onUpdate={(e) => {
|
|
139
|
-
props.onUpdate && props.onUpdate(e.editor.storage.markdown.getMarkdown());
|
|
140
|
-
}}
|
|
141
|
-
></EditorProvider>
|
|
142
|
-
</div>
|
|
143
|
-
);
|
|
144
|
-
};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
|
|
3
|
-
interface SpinnerProps {
|
|
4
|
-
text?: string;
|
|
5
|
-
size?: string;
|
|
6
|
-
className?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const Spinner: React.FC<SpinnerProps> = ({ text, className, size = '30px' }) => {
|
|
10
|
-
return (
|
|
11
|
-
<div className={'flex items-center space-x-2 ' + className}>
|
|
12
|
-
<svg
|
|
13
|
-
style={{ width: size, height: size }}
|
|
14
|
-
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
15
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
16
|
-
fill="none"
|
|
17
|
-
viewBox="0 0 24 24"
|
|
18
|
-
>
|
|
19
|
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
20
|
-
<path
|
|
21
|
-
className="opacity-75"
|
|
22
|
-
fill="currentColor"
|
|
23
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
24
|
-
></path>
|
|
25
|
-
</svg>
|
|
26
|
-
{text && <span className="">{text}</span>}
|
|
27
|
-
</div>
|
|
28
|
-
);
|
|
29
|
-
};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export function setTheme(theme?: string | null): void {
|
|
2
|
-
document.documentElement.classList.add('dark:text-gray-200');
|
|
3
|
-
|
|
4
|
-
if (isDarkTheme(theme)) {
|
|
5
|
-
document.documentElement.setAttribute('data-theme', 'dark');
|
|
6
|
-
document.documentElement.classList.add('dark', 'dark:bg-gray-950');
|
|
7
|
-
document.documentElement.style.background = 'hsl(var(--background))';
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function isDarkTheme(theme?: string | null): boolean {
|
|
12
|
-
// If no theme provided, try to get from URL as fallback (for standalone mode)
|
|
13
|
-
if (!theme) {
|
|
14
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
15
|
-
theme = urlParams.get('theme');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (!theme || theme === 'system') {
|
|
19
|
-
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return theme === 'dark';
|
|
23
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export function isFullscreen(): boolean {
|
|
2
|
-
return !!document.fullscreenElement;
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
export function triggerFullscreen(onStateChange: (isFullscreen: boolean) => void, selector?: string): void {
|
|
6
|
-
document.addEventListener('fullscreenchange', () => {
|
|
7
|
-
onStateChange(isFullscreen());
|
|
8
|
-
});
|
|
9
|
-
try {
|
|
10
|
-
const ref = document.querySelector(selector || '#root') as HTMLElement;
|
|
11
|
-
if (!isFullscreen()) {
|
|
12
|
-
// @ts-ignore
|
|
13
|
-
void (ref.requestFullscreen() || ref.webkitRequestFullscreen());
|
|
14
|
-
} else {
|
|
15
|
-
// @ts-ignore
|
|
16
|
-
void (document.exitFullscreen() || document.webkitExitFullscreen());
|
|
17
|
-
}
|
|
18
|
-
} catch (error: any) {
|
|
19
|
-
console.error('Failed to enter fullscreen', error.message);
|
|
20
|
-
}
|
|
21
|
-
onStateChange(isFullscreen());
|
|
22
|
-
}
|
|
File without changes
|