major improvements to the app
Browse files- README.md +2 -1
- package-lock.json +18 -1
- package.json +3 -1
- src/app.tsx +70 -167
- src/components/alert/index.tsx +13 -0
- src/components/alert/style.module.scss +18 -0
- src/components/button/index.tsx +2 -2
- src/components/input/index.tsx +2 -23
- src/components/input/style.module.scss +19 -3
- src/components/layout/index.tsx +7 -5
- src/components/layout/style.module.scss +15 -2
- src/components/pagination/index.tsx +46 -0
- src/components/pagination/style.module.scss +73 -0
- src/components/preview/index.tsx +64 -53
- src/components/preview/style.module.scss +5 -0
- src/components/radio/index.tsx +43 -0
- src/components/radio/style.module.scss +53 -0
- src/components/settings/index.tsx +59 -28
- src/components/settings/style.module.scss +15 -0
- src/components/spinner/style.module.scss +14 -10
- src/components/topic/index.tsx +91 -17
- src/components/topic/style.module.scss +47 -3
- src/components/topics/index.tsx +155 -43
- src/components/topics/style.module.scss +50 -3
- src/components/wysiwyg/index.tsx +287 -0
- src/components/wysiwyg/style.module.scss +108 -0
- src/contexts/log.ts +27 -0
- src/contexts/route.ts +120 -0
- src/contexts/settings.ts +41 -0
- src/contexts/topics.ts +230 -0
- src/index.ts +20 -3
- src/style.module.scss +1 -1
- src/utils/beam.ts +41 -0
- src/utils/context.ts +93 -0
- src/utils/dates.ts +14 -12
- src/utils/misc.ts +26 -0
- src/utils/model.ts +99 -17
- src/utils/oobabooga.ts +10 -57
- src/utils/route.ts +0 -9
- src/utils/settings.ts +0 -30
- src/utils/topics.ts +0 -135
- tsconfig.json +2 -3
- vite.config.ts +5 -0
README.md
CHANGED
@@ -3,7 +3,8 @@ title: JVCGPT
|
|
3 |
emoji: 🤣
|
4 |
colorFrom: blue
|
5 |
colorTo: blue
|
6 |
-
sdk:
|
|
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
---
|
|
|
3 |
emoji: 🤣
|
4 |
colorFrom: blue
|
5 |
colorTo: blue
|
6 |
+
sdk: static
|
7 |
+
app_file: ./dist/index.html
|
8 |
pinned: false
|
9 |
license: mit
|
10 |
---
|
package-lock.json
CHANGED
@@ -5,9 +5,11 @@
|
|
5 |
"packages": {
|
6 |
"": {
|
7 |
"dependencies": {
|
|
|
8 |
"classnames": "^2.5.1",
|
9 |
"preact": "^10.25.3",
|
10 |
-
"preact-feather": "^4.2.1"
|
|
|
11 |
},
|
12 |
"devDependencies": {
|
13 |
"@preact/preset-vite": "^2.9.3",
|
@@ -1738,6 +1740,12 @@
|
|
1738 |
"dev": true,
|
1739 |
"license": "MIT"
|
1740 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
1741 |
"node_modules/@ungap/structured-clone": {
|
1742 |
"version": "1.2.1",
|
1743 |
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz",
|
@@ -5661,6 +5669,15 @@
|
|
5661 |
"dev": true,
|
5662 |
"license": "MIT"
|
5663 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5664 |
"node_modules/to-regex-range": {
|
5665 |
"version": "5.0.1",
|
5666 |
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
|
5 |
"packages": {
|
6 |
"": {
|
7 |
"dependencies": {
|
8 |
+
"@types/throttle-debounce": "^5.0.2",
|
9 |
"classnames": "^2.5.1",
|
10 |
"preact": "^10.25.3",
|
11 |
+
"preact-feather": "^4.2.1",
|
12 |
+
"throttle-debounce": "^5.0.2"
|
13 |
},
|
14 |
"devDependencies": {
|
15 |
"@preact/preset-vite": "^2.9.3",
|
|
|
1740 |
"dev": true,
|
1741 |
"license": "MIT"
|
1742 |
},
|
1743 |
+
"node_modules/@types/throttle-debounce": {
|
1744 |
+
"version": "5.0.2",
|
1745 |
+
"resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
1746 |
+
"integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==",
|
1747 |
+
"license": "MIT"
|
1748 |
+
},
|
1749 |
"node_modules/@ungap/structured-clone": {
|
1750 |
"version": "1.2.1",
|
1751 |
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz",
|
|
|
5669 |
"dev": true,
|
5670 |
"license": "MIT"
|
5671 |
},
|
5672 |
+
"node_modules/throttle-debounce": {
|
5673 |
+
"version": "5.0.2",
|
5674 |
+
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
5675 |
+
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
|
5676 |
+
"license": "MIT",
|
5677 |
+
"engines": {
|
5678 |
+
"node": ">=12.22"
|
5679 |
+
}
|
5680 |
+
},
|
5681 |
"node_modules/to-regex-range": {
|
5682 |
"version": "5.0.1",
|
5683 |
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
package.json
CHANGED
@@ -7,9 +7,11 @@
|
|
7 |
"preview": "vite preview"
|
8 |
},
|
9 |
"dependencies": {
|
|
|
10 |
"classnames": "^2.5.1",
|
11 |
"preact": "^10.25.3",
|
12 |
-
"preact-feather": "^4.2.1"
|
|
|
13 |
},
|
14 |
"devDependencies": {
|
15 |
"@preact/preset-vite": "^2.9.3",
|
|
|
7 |
"preview": "vite preview"
|
8 |
},
|
9 |
"dependencies": {
|
10 |
+
"@types/throttle-debounce": "^5.0.2",
|
11 |
"classnames": "^2.5.1",
|
12 |
"preact": "^10.25.3",
|
13 |
+
"preact-feather": "^4.2.1",
|
14 |
+
"throttle-debounce": "^5.0.2"
|
15 |
},
|
16 |
"devDependencies": {
|
17 |
"@preact/preset-vite": "^2.9.3",
|
src/app.tsx
CHANGED
@@ -1,179 +1,83 @@
|
|
1 |
-
import {JSX} from "preact";
|
2 |
-
import {useState, useEffect, useCallback} from "preact/hooks";
|
3 |
import "./styles.scss";
|
|
|
|
|
4 |
import style from "./style.module.scss";
|
5 |
import {Container} from "./components/container";
|
6 |
-
import {loadTopics, saveTopics} from "./utils/topics";
|
7 |
-
import {Topic} from "./utils/topics";
|
8 |
import {Topics} from "./components/topics";
|
9 |
import {Topic as TopicComponent} from "./components/topic";
|
10 |
-
import {Route, routes, RouteSetter} from "./utils/route";
|
11 |
import {Settings} from "./components/settings";
|
12 |
import {Layout} from "./components/layout";
|
13 |
-
import {
|
14 |
-
import {
|
15 |
-
import {tokenizeTopic} from "./utils/model";
|
16 |
|
17 |
export function App(): JSX.Element {
|
18 |
-
const [route,
|
19 |
-
const [
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
setPendingGeneration(true);
|
42 |
-
const posts = await generatePosts(settings, postsCount, topics.find(t => t.id === topicId));
|
43 |
-
const newTopics = [...topics]; // Clone topics to avoid bugs
|
44 |
-
const foundIndex = newTopics.findIndex(t => t.id === topicId);
|
45 |
-
newTopics[foundIndex].posts = newTopics[foundIndex].posts.concat(posts);
|
46 |
-
setTopics(newTopics)
|
47 |
-
setPendingGeneration(false);
|
48 |
-
}
|
49 |
-
|
50 |
-
// useEffect(() => {
|
51 |
-
// setTopics(loadTopics());
|
52 |
-
// }, []);
|
53 |
-
|
54 |
-
const [settings, setSettings] = useState(fetchSettings)
|
55 |
-
|
56 |
-
useEffect(() => {
|
57 |
-
saveSettings(settings);
|
58 |
-
}, [settings]);
|
59 |
-
|
60 |
-
const resetApp = () => {
|
61 |
-
resetSettings();
|
62 |
-
setSettings(fetchSettings);
|
63 |
-
setTopics([]);
|
64 |
-
}
|
65 |
-
|
66 |
-
const updateRoute = useCallback(() => {
|
67 |
-
const url = new URL(window.location.href);
|
68 |
-
const route = url.searchParams.get("route");
|
69 |
-
if (route && route in routes) {
|
70 |
-
_setRoute(route as keyof typeof routes);
|
71 |
-
}
|
72 |
-
const page = url.searchParams.get("page");
|
73 |
-
if (page) {
|
74 |
-
setPage(parseInt(page));
|
75 |
-
}
|
76 |
-
const id = url.searchParams.get("id");
|
77 |
-
if (id) {
|
78 |
-
setTopicId(id);
|
79 |
-
}
|
80 |
-
}, []);
|
81 |
-
|
82 |
-
// Init page from url
|
83 |
-
useEffect(() => {
|
84 |
-
updateRoute()
|
85 |
-
}, []);
|
86 |
-
|
87 |
-
// Listen for URl change
|
88 |
-
useEffect(() => {
|
89 |
-
function listener() {
|
90 |
-
updateRoute()
|
91 |
-
}
|
92 |
-
|
93 |
-
window.addEventListener('popstate', listener);
|
94 |
-
|
95 |
-
return () => {
|
96 |
-
window.removeEventListener('popstate', listener)
|
97 |
-
}
|
98 |
-
}, []);
|
99 |
-
|
100 |
-
|
101 |
-
// Update URL params on route change
|
102 |
-
const setRoute: RouteSetter = useCallback((route: Route, page?: number, id?: string) => {
|
103 |
-
const url = new URL(window.location.href);
|
104 |
-
url.searchParams.set("route", route);
|
105 |
-
_setRoute(route);
|
106 |
-
|
107 |
-
if (page !== undefined) {
|
108 |
-
url.searchParams.set("page", String(page));
|
109 |
-
setPage(page);
|
110 |
-
} else {
|
111 |
-
url.searchParams.delete("page");
|
112 |
-
setPage(0);
|
113 |
-
}
|
114 |
-
|
115 |
-
if (id !== undefined) {
|
116 |
-
url.searchParams.set("id", id);
|
117 |
-
setTopicId(id);
|
118 |
-
} else {
|
119 |
-
url.searchParams.delete("id");
|
120 |
-
setTopicId(null);
|
121 |
-
}
|
122 |
-
|
123 |
-
const newUrl = url.toString();
|
124 |
-
|
125 |
-
// Avoid to push the same URL mutiple time
|
126 |
-
if (newUrl !== window.location.href) {
|
127 |
-
window.history.pushState({}, "", newUrl);
|
128 |
-
}
|
129 |
-
}, []);
|
130 |
-
|
131 |
-
let routeComponent: JSX.Element = undefined;
|
132 |
-
let breadcrumbs: string = undefined;
|
133 |
-
let title: string = undefined;
|
134 |
-
|
135 |
-
switch (route) {
|
136 |
-
case routes.home:
|
137 |
-
routeComponent = <Topics
|
138 |
-
topics={topics}
|
139 |
-
setRoute={setRoute}
|
140 |
-
settings={settings}
|
141 |
-
setSettings={setSettings}
|
142 |
-
generateTopic={_generateTopic}
|
143 |
-
pendingGeneration={pendingGeneration}
|
144 |
-
latestGeneratedTopicId={latestGeneratedTopicId}
|
145 |
-
/>
|
146 |
-
breadcrumbs = "accueil"
|
147 |
-
title = "Liste des sujets"
|
148 |
break;
|
149 |
-
case
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
} else {
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
title = `Chargement...`
|
159 |
-
} else {
|
160 |
-
const topic = topics.find(t => t.id === topicId);
|
161 |
-
routeComponent = <TopicComponent
|
162 |
-
topic={topic}
|
163 |
-
settings={settings}
|
164 |
-
setSettings={setSettings}
|
165 |
-
addPosts={addPosts}
|
166 |
-
pendingGeneration={pendingGeneration}
|
167 |
-
/>
|
168 |
-
breadcrumbs = `accueil / ${topic.title}`
|
169 |
-
title = `Sujet : ${topic.title}`
|
170 |
-
}
|
171 |
}
|
172 |
-
|
173 |
break;
|
174 |
-
case
|
175 |
-
routeComponent = <Settings
|
176 |
-
breadcrumbs =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
title = "Paramètres"
|
178 |
break;
|
179 |
}
|
@@ -183,21 +87,20 @@ export function App(): JSX.Element {
|
|
183 |
<header className={style.header}>
|
184 |
<Container>
|
185 |
<h1 className={style.logo}>
|
186 |
-
<a href=
|
187 |
e.preventDefault();
|
188 |
-
setRoute(routes.home);
|
189 |
}}>
|
190 |
JVCGPT
|
191 |
</a>
|
192 |
</h1>
|
193 |
</Container>
|
194 |
</header>
|
195 |
-
<main>
|
196 |
<Container>
|
197 |
<Layout
|
198 |
breadcrumbs={breadcrumbs}
|
199 |
title={title}
|
200 |
-
setRoute={setRoute}
|
201 |
>
|
202 |
{routeComponent}
|
203 |
</Layout>
|
|
|
|
|
|
|
1 |
import "./styles.scss";
|
2 |
+
import {JSX} from "preact";
|
3 |
+
import {useState, useContext} from "preact/hooks";
|
4 |
import style from "./style.module.scss";
|
5 |
import {Container} from "./components/container";
|
|
|
|
|
6 |
import {Topics} from "./components/topics";
|
7 |
import {Topic as TopicComponent} from "./components/topic";
|
|
|
8 |
import {Settings} from "./components/settings";
|
9 |
import {Layout} from "./components/layout";
|
10 |
+
import {routeCtx, routes} from "@/contexts/route";
|
11 |
+
import {topicsCtx} from "@/contexts/topics";
|
|
|
12 |
|
13 |
export function App(): JSX.Element {
|
14 |
+
const [route, setRoute] = useContext(routeCtx);
|
15 |
+
const [topicsContext] = useContext(topicsCtx);
|
16 |
+
|
17 |
+
// Router switch
|
18 |
+
let routeComponent: JSX.Element;
|
19 |
+
let breadcrumbs: JSX.Element;
|
20 |
+
let title: string;
|
21 |
+
|
22 |
+
switch (route.name) {
|
23 |
+
case "home":
|
24 |
+
routeComponent = <Topics page={route.args[0]} setPage={n => setRoute(routes.home(n))}/>
|
25 |
+
breadcrumbs = (
|
26 |
+
<a
|
27 |
+
href={routes.home(0).location}
|
28 |
+
onClick={e => {
|
29 |
+
e.preventDefault();
|
30 |
+
setRoute(routes.home(0));
|
31 |
+
}}
|
32 |
+
>
|
33 |
+
accueil
|
34 |
+
</a>
|
35 |
+
);
|
36 |
+
title = "Liste des sujets";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
break;
|
38 |
+
case "topic":
|
39 |
+
const topic = topicsContext.topics.find(t => t.id === route.args[0]);
|
40 |
+
if (topic) {
|
41 |
+
routeComponent = <TopicComponent page={route.args[1] - 1} setPage={n => setRoute(routes.topic(topic.id, n + 1))} topic={topic}/>
|
42 |
+
breadcrumbs = (
|
43 |
+
<>
|
44 |
+
<a
|
45 |
+
href={routes.home(0).location}
|
46 |
+
onClick={e => {
|
47 |
+
e.preventDefault();
|
48 |
+
setRoute(routes.home(0));
|
49 |
+
}}
|
50 |
+
>
|
51 |
+
accueil
|
52 |
+
</a>
|
53 |
+
<span> / </span>
|
54 |
+
<b>{topic.title}</b>
|
55 |
+
</>
|
56 |
+
)
|
57 |
+
title = `Sujet : ${topic.title}`
|
58 |
} else {
|
59 |
+
routeComponent = <div></div>
|
60 |
+
breadcrumbs = <>410</>
|
61 |
+
title = `410 :)`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
}
|
|
|
63 |
break;
|
64 |
+
case "settings":
|
65 |
+
routeComponent = <Settings/>
|
66 |
+
breadcrumbs = (
|
67 |
+
<>
|
68 |
+
<a
|
69 |
+
href={routes.home(0).location}
|
70 |
+
onClick={e => {
|
71 |
+
e.preventDefault();
|
72 |
+
setRoute(routes.home(0));
|
73 |
+
}}
|
74 |
+
>
|
75 |
+
accueil
|
76 |
+
</a>
|
77 |
+
<span> / </span>
|
78 |
+
<b>paramètres</b>
|
79 |
+
</>
|
80 |
+
)
|
81 |
title = "Paramètres"
|
82 |
break;
|
83 |
}
|
|
|
87 |
<header className={style.header}>
|
88 |
<Container>
|
89 |
<h1 className={style.logo}>
|
90 |
+
<a href={routes.home(0).location} onClick={e => {
|
91 |
e.preventDefault();
|
92 |
+
setRoute(routes.home(0));
|
93 |
}}>
|
94 |
JVCGPT
|
95 |
</a>
|
96 |
</h1>
|
97 |
</Container>
|
98 |
</header>
|
99 |
+
<main className={style.main}>
|
100 |
<Container>
|
101 |
<Layout
|
102 |
breadcrumbs={breadcrumbs}
|
103 |
title={title}
|
|
|
104 |
>
|
105 |
{routeComponent}
|
106 |
</Layout>
|
src/components/alert/index.tsx
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import style from "./style.module.scss";
|
2 |
+
|
3 |
+
export function Alert(props: {
|
4 |
+
lines: string[]
|
5 |
+
}) {
|
6 |
+
if(props.lines.length < 1) return null;
|
7 |
+
|
8 |
+
return (
|
9 |
+
<div className={style.alert}>
|
10 |
+
{props.lines.map(line => <p>{line}</p>)}
|
11 |
+
</div>
|
12 |
+
);
|
13 |
+
}
|
src/components/alert/style.module.scss
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.alert {
|
2 |
+
position: relative;
|
3 |
+
padding: 1rem;
|
4 |
+
margin-bottom: 1rem;
|
5 |
+
color: #842029;
|
6 |
+
background-color: #f8d7da;
|
7 |
+
border: #f5c2c7;
|
8 |
+
border-radius: 0.75rem;
|
9 |
+
|
10 |
+
p {
|
11 |
+
margin-top: 0;
|
12 |
+
margin-bottom: 1rem;
|
13 |
+
|
14 |
+
&:last-child {
|
15 |
+
margin-bottom: 0;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
}
|
src/components/button/index.tsx
CHANGED
@@ -14,7 +14,7 @@ export function Button(props: {
|
|
14 |
const disabled = props.disabled || props.loading;
|
15 |
const buttonClass = cn(style.btn, {[style.secondary]: props.secondary}, 'button', props.className, {[style.disabled]:disabled});
|
16 |
|
17 |
-
let spinner: JSX.Element
|
18 |
|
19 |
if (props.loading) {
|
20 |
spinner = <span className={style.spinner}/>
|
@@ -24,7 +24,7 @@ export function Button(props: {
|
|
24 |
<button
|
25 |
type="button"
|
26 |
onClick={() => {
|
27 |
-
if (!disabled) {
|
28 |
props.onClick()
|
29 |
}
|
30 |
}}
|
|
|
14 |
const disabled = props.disabled || props.loading;
|
15 |
const buttonClass = cn(style.btn, {[style.secondary]: props.secondary}, 'button', props.className, {[style.disabled]:disabled});
|
16 |
|
17 |
+
let spinner: JSX.Element|undefined;
|
18 |
|
19 |
if (props.loading) {
|
20 |
spinner = <span className={style.spinner}/>
|
|
|
24 |
<button
|
25 |
type="button"
|
26 |
onClick={() => {
|
27 |
+
if (props.onClick && !disabled) {
|
28 |
props.onClick()
|
29 |
}
|
30 |
}}
|
src/components/input/index.tsx
CHANGED
@@ -30,12 +30,8 @@ export const Input: FunctionalComponent<InputProps<string | number>> = ({
|
|
30 |
id,
|
31 |
name,
|
32 |
}) => {
|
33 |
-
const [focused, setFocused] = useState(false);
|
34 |
|
35 |
-
const inputClass = cn(style.input,
|
36 |
-
focused,
|
37 |
-
disabled,
|
38 |
-
});
|
39 |
|
40 |
const handleInputChange: JSX.GenericEventHandler<HTMLInputElement> = (event) => {
|
41 |
console.log("handleInputChange")
|
@@ -46,7 +42,7 @@ export const Input: FunctionalComponent<InputProps<string | number>> = ({
|
|
46 |
const Icon = icon;
|
47 |
|
48 |
return (
|
49 |
-
<div className={style.wrapper}>
|
50 |
<Icon className={style.icon} size={18}/>
|
51 |
<input
|
52 |
// required=""
|
@@ -63,24 +59,7 @@ export const Input: FunctionalComponent<InputProps<string | number>> = ({
|
|
63 |
onInput={handleInputChange}
|
64 |
className={inputClass}
|
65 |
disabled={disabled}
|
66 |
-
onFocus={() => setFocused(true)}
|
67 |
-
onBlur={() => setFocused(false)}
|
68 |
/>
|
69 |
</div>
|
70 |
)
|
71 |
-
|
72 |
-
return (
|
73 |
-
<input
|
74 |
-
type={type}
|
75 |
-
id={id}
|
76 |
-
name={name}
|
77 |
-
value={value}
|
78 |
-
placeholder={placeholder}
|
79 |
-
onChange={handleInputChange}
|
80 |
-
className={inputClass}
|
81 |
-
disabled={disabled}
|
82 |
-
onFocus={() => setFocused(true)}
|
83 |
-
onBlur={() => setFocused(false)}
|
84 |
-
/>
|
85 |
-
);
|
86 |
};
|
|
|
30 |
id,
|
31 |
name,
|
32 |
}) => {
|
|
|
33 |
|
34 |
+
const inputClass = cn(style.input, className);
|
|
|
|
|
|
|
35 |
|
36 |
const handleInputChange: JSX.GenericEventHandler<HTMLInputElement> = (event) => {
|
37 |
console.log("handleInputChange")
|
|
|
42 |
const Icon = icon;
|
43 |
|
44 |
return (
|
45 |
+
<div className={cn(style.wrapper, {[style.disabled]: disabled})}>
|
46 |
<Icon className={style.icon} size={18}/>
|
47 |
<input
|
48 |
// required=""
|
|
|
59 |
onInput={handleInputChange}
|
60 |
className={inputClass}
|
61 |
disabled={disabled}
|
|
|
|
|
62 |
/>
|
63 |
</div>
|
64 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
};
|
src/components/input/style.module.scss
CHANGED
@@ -9,11 +9,12 @@
|
|
9 |
left: .625rem;
|
10 |
font-size: 1.125rem;
|
11 |
line-height: 1em;
|
|
|
12 |
}
|
13 |
|
14 |
.input {
|
15 |
-
|
16 |
display: block;
|
|
|
17 |
width: 100%;
|
18 |
//padding: 0.4375rem 0.75rem;
|
19 |
//font-size: 0.9375rem;
|
@@ -25,7 +26,7 @@
|
|
25 |
border: 0.0625rem solid var(--input-border-color);
|
26 |
-webkit-appearance: none;
|
27 |
appearance: none;
|
28 |
-
border-radius: 0.
|
29 |
box-shadow: inset 0 0.0625rem 0.125rem rgba(0, 0, 0, .075);
|
30 |
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
31 |
|
@@ -34,7 +35,7 @@
|
|
34 |
//padding-left: 0.4375rem;
|
35 |
padding-left: 2.5rem;
|
36 |
padding-bottom: 0;
|
37 |
-
font-weight: 700;
|
38 |
height: 2.5rem;
|
39 |
font-size: 1rem;
|
40 |
|
@@ -42,4 +43,19 @@
|
|
42 |
color: var(--input-placeholder-color);
|
43 |
font-weight: 400;
|
44 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
}
|
|
|
9 |
left: .625rem;
|
10 |
font-size: 1.125rem;
|
11 |
line-height: 1em;
|
12 |
+
z-index: 1;
|
13 |
}
|
14 |
|
15 |
.input {
|
|
|
16 |
display: block;
|
17 |
+
position: relative;
|
18 |
width: 100%;
|
19 |
//padding: 0.4375rem 0.75rem;
|
20 |
//font-size: 0.9375rem;
|
|
|
26 |
border: 0.0625rem solid var(--input-border-color);
|
27 |
-webkit-appearance: none;
|
28 |
appearance: none;
|
29 |
+
border-radius: 0.25rem;
|
30 |
box-shadow: inset 0 0.0625rem 0.125rem rgba(0, 0, 0, .075);
|
31 |
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
32 |
|
|
|
35 |
//padding-left: 0.4375rem;
|
36 |
padding-left: 2.5rem;
|
37 |
padding-bottom: 0;
|
38 |
+
//font-weight: 700;
|
39 |
height: 2.5rem;
|
40 |
font-size: 1rem;
|
41 |
|
|
|
43 |
color: var(--input-placeholder-color);
|
44 |
font-weight: 400;
|
45 |
}
|
46 |
+
}
|
47 |
+
|
48 |
+
.disabled {
|
49 |
+
&:before {
|
50 |
+
content: '';
|
51 |
+
position: absolute;
|
52 |
+
cursor: not-allowed;
|
53 |
+
z-index: 2;
|
54 |
+
top: 0;
|
55 |
+
right: 0;
|
56 |
+
bottom: 0;
|
57 |
+
left: 0;
|
58 |
+
background-color: var(--filters-bg-color);
|
59 |
+
opacity: .25;
|
60 |
+
}
|
61 |
}
|
src/components/layout/index.tsx
CHANGED
@@ -1,14 +1,16 @@
|
|
1 |
-
import type {ComponentChildren} from "preact";
|
2 |
import style from "./style.module.scss"
|
3 |
import {Settings} from "preact-feather"
|
4 |
-
import {
|
|
|
5 |
|
6 |
export function Layout(props: {
|
7 |
-
breadcrumbs:
|
8 |
title: string,
|
9 |
-
setRoute: (route: Route) => void,
|
10 |
children: ComponentChildren
|
11 |
}) {
|
|
|
|
|
12 |
return(
|
13 |
<div>
|
14 |
<nav className={style.breadcrumbs}>
|
@@ -16,7 +18,7 @@ export function Layout(props: {
|
|
16 |
<div className={style.actions}>
|
17 |
<a href="#" title="Paramètres" onClick={e => {
|
18 |
e.preventDefault();
|
19 |
-
|
20 |
}}>
|
21 |
<Settings size={18}/>
|
22 |
</a>
|
|
|
1 |
+
import type {ComponentChildren, JSX} from "preact";
|
2 |
import style from "./style.module.scss"
|
3 |
import {Settings} from "preact-feather"
|
4 |
+
import {routeCtx, routes} from "@/contexts/route";
|
5 |
+
import {useContext} from "preact/hooks";
|
6 |
|
7 |
export function Layout(props: {
|
8 |
+
breadcrumbs: JSX.Element,
|
9 |
title: string,
|
|
|
10 |
children: ComponentChildren
|
11 |
}) {
|
12 |
+
const [,setRoute] = useContext(routeCtx);
|
13 |
+
|
14 |
return(
|
15 |
<div>
|
16 |
<nav className={style.breadcrumbs}>
|
|
|
18 |
<div className={style.actions}>
|
19 |
<a href="#" title="Paramètres" onClick={e => {
|
20 |
e.preventDefault();
|
21 |
+
setRoute(routes.settings());
|
22 |
}}>
|
23 |
<Settings size={18}/>
|
24 |
</a>
|
src/components/layout/style.module.scss
CHANGED
@@ -1,11 +1,24 @@
|
|
1 |
.breadcrumbs {
|
2 |
-
font-weight:
|
3 |
color: var(--text-muted-color);
|
4 |
font-size: .8125rem;
|
5 |
margin-top: 1.25rem;
|
6 |
margin-bottom: 0.9375rem;
|
7 |
-
|
8 |
display: flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
}
|
10 |
|
11 |
.actions {
|
|
|
1 |
.breadcrumbs {
|
2 |
+
font-weight: 400;
|
3 |
color: var(--text-muted-color);
|
4 |
font-size: .8125rem;
|
5 |
margin-top: 1.25rem;
|
6 |
margin-bottom: 0.9375rem;
|
7 |
+
white-space: pre-wrap;
|
8 |
display: flex;
|
9 |
+
|
10 |
+
a {
|
11 |
+
text-decoration: none;
|
12 |
+
|
13 |
+
&:hover {
|
14 |
+
color: var(--text-muted-color);
|
15 |
+
text-decoration: underline;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
|
19 |
+
b {
|
20 |
+
font-weight: 700;
|
21 |
+
}
|
22 |
}
|
23 |
|
24 |
.actions {
|
src/components/pagination/index.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import style from "./style.module.scss";
|
2 |
+
import {
|
3 |
+
ChevronRight,
|
4 |
+
ChevronLeft,
|
5 |
+
} from "preact-feather";
|
6 |
+
import cn from "classnames";
|
7 |
+
|
8 |
+
const maxNumber = 10;
|
9 |
+
|
10 |
+
export function Pagination(props: {
|
11 |
+
pageCount: number;
|
12 |
+
page: number;
|
13 |
+
setPage: (page: number) => void;
|
14 |
+
}) {
|
15 |
+
return (
|
16 |
+
<div className={style.pagination}>
|
17 |
+
<div
|
18 |
+
className={cn(style.chevronWrapper, {[style.disabled]: props.page <= 0})}
|
19 |
+
onClick={() => props.setPage(Math.max(props.page - 1, 0))}
|
20 |
+
title="Précédent"
|
21 |
+
>
|
22 |
+
<ChevronLeft/>
|
23 |
+
</div>
|
24 |
+
<div className={style.numbers}>
|
25 |
+
{
|
26 |
+
Array.from({length: props.pageCount}, (_, i) => i)
|
27 |
+
.map(n => (
|
28 |
+
<span
|
29 |
+
className={cn(style.number, {[style.active]: n === props.page})}
|
30 |
+
onClick={() => props.setPage(n)}
|
31 |
+
>
|
32 |
+
{n + 1}
|
33 |
+
</span>
|
34 |
+
))
|
35 |
+
}
|
36 |
+
</div>
|
37 |
+
<div
|
38 |
+
className={cn(style.chevronWrapper, {[style.disabled]: props.page >= props.pageCount - 1})}
|
39 |
+
onClick={() => props.setPage(Math.min(props.page + 1, props.pageCount - 1))}
|
40 |
+
title="Suivant"
|
41 |
+
>
|
42 |
+
<ChevronRight/>
|
43 |
+
</div>
|
44 |
+
</div>
|
45 |
+
);
|
46 |
+
}
|
src/components/pagination/style.module.scss
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.pagination {
|
2 |
+
display: flex;
|
3 |
+
flex-direction: row;
|
4 |
+
width: 100%;
|
5 |
+
justify-content: space-between;
|
6 |
+
align-items: center;
|
7 |
+
}
|
8 |
+
|
9 |
+
.chevronWrapper {
|
10 |
+
display: inline-flex;
|
11 |
+
width: 2.5rem;
|
12 |
+
height: 2.5rem;
|
13 |
+
line-height: 2.5rem;
|
14 |
+
background: var(--block-bg-color);
|
15 |
+
text-align: center;
|
16 |
+
vertical-align: top;
|
17 |
+
color: var(--text-color);
|
18 |
+
border-radius: 1.25rem;
|
19 |
+
border: 0.0625rem solid var(--border-color);
|
20 |
+
justify-content: center;
|
21 |
+
align-items: center;
|
22 |
+
|
23 |
+
&:not(.disabled) {
|
24 |
+
cursor: pointer;
|
25 |
+
|
26 |
+
&:hover {
|
27 |
+
background: #0160ee;
|
28 |
+
color: #fff;
|
29 |
+
border-color: #0160ee;
|
30 |
+
}
|
31 |
+
}
|
32 |
+
}
|
33 |
+
|
34 |
+
.disabled {
|
35 |
+
opacity: .3;
|
36 |
+
}
|
37 |
+
|
38 |
+
.numbers {
|
39 |
+
overflow: hidden;
|
40 |
+
font-size: .8125rem;
|
41 |
+
font-weight: 700;
|
42 |
+
display: flex;
|
43 |
+
flex-wrap: wrap;
|
44 |
+
justify-content: center;
|
45 |
+
align-items: center;
|
46 |
+
}
|
47 |
+
|
48 |
+
.number {
|
49 |
+
display: inline-block;
|
50 |
+
height: 1.875rem;
|
51 |
+
min-width: 1.875rem;
|
52 |
+
line-height: 1.875rem;
|
53 |
+
padding: 0 0.3125rem;
|
54 |
+
text-align: center;
|
55 |
+
vertical-align: middle;
|
56 |
+
color: var(--link-color);
|
57 |
+
text-decoration: none;
|
58 |
+
|
59 |
+
&:not(.active) {
|
60 |
+
cursor: pointer;
|
61 |
+
|
62 |
+
&:hover {
|
63 |
+
color: var(--text-hover-secondary);
|
64 |
+
text-decoration: none;
|
65 |
+
}
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
.active {
|
70 |
+
background: var(--link-color);
|
71 |
+
color: var(--text-light);
|
72 |
+
border-radius: 50%;
|
73 |
+
}
|
src/components/preview/index.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
import style from "./style.module.scss";
|
2 |
import {useMemo} from "preact/hooks";
|
3 |
-
import {smileysMap} from "
|
4 |
|
5 |
// const blocks = {
|
6 |
// text: "text",
|
@@ -16,18 +16,9 @@ import {smileysMap} from "../../utils/smileys";
|
|
16 |
export function Preview(props: {
|
17 |
raw: string,
|
18 |
}) {
|
19 |
-
// console.log(parseRaw(props.raw))
|
20 |
-
|
21 |
-
// const segments = useMemo(() => {
|
22 |
-
// return parseRaw(props.raw);
|
23 |
-
// }, [props.raw]);
|
24 |
-
|
25 |
const html = useMemo(() => {
|
26 |
-
// console.log(escapeHtml(props.raw))
|
27 |
const escaped = escapeHtml(props.raw);
|
28 |
-
|
29 |
-
// console.log(withBr)
|
30 |
-
return injectHTML(escaped).replace(/\n/g, '<br/>')
|
31 |
}, [props.raw]);
|
32 |
|
33 |
return (
|
@@ -49,47 +40,68 @@ export function Preview(props: {
|
|
49 |
// )
|
50 |
}
|
51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
const jvcodeMap: [RegExp, string | ((reg: RegExp, raw: string) => string)][] = [
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
"$1<img width=\"68\" height=\"51\" alt=\"noelshak\" src=\"https://image.noelshack.com/minis/$2\"/>"
|
57 |
],
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
61 |
],
|
62 |
-
[ //
|
63 |
-
|
64 |
(reg, raw) => {
|
65 |
-
//
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
const length = match[0].length;
|
72 |
-
|
73 |
-
return raw.substring(0, index) + `<blockquote>${match[0].replace(/^>/gm, "")}</blockquote>` + raw.substring(index + length);
|
74 |
-
// console.log(match)
|
75 |
-
// const content = match[0].replace(/^>/gm, "");
|
76 |
-
// return `<blockquote>${content}</blockquote>`;
|
77 |
}
|
78 |
],
|
79 |
[ // Spoil
|
80 |
/<spoil>(.*?)<\/spoil>/gm,
|
81 |
-
(reg, raw) => {
|
82 |
return raw.replace(reg, (_, matched) => {
|
83 |
const randomId = (Math.random() + 1).toString(36).substring(2);
|
84 |
return `<span class="bloc-spoil-jv"><input type="checkbox" id="${randomId}" class="open-spoil"><label class="barre-head" for="${randomId}"><span class="txt-spoil">Spoil</span></label><span class="contenu-spoil">${matched}</span></span>`;
|
85 |
});
|
86 |
}
|
87 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
[ // Regular links
|
89 |
-
/(
|
90 |
"$1<a href=\"$2\" target=\"_blank\">$2</a>"
|
91 |
],
|
92 |
|
|
|
|
|
93 |
// Generate regexes for smileys
|
94 |
// ...smileysMap.map((maping) => {
|
95 |
// return [new RegExp(
|
@@ -106,35 +118,34 @@ const jvcodeMap: [RegExp, string | ((reg: RegExp, raw: string) => string)][] = [
|
|
106 |
// )
|
107 |
// return [/(https?:\/\/image\.noelshack\.com\/\S+)/g, "<img width=\"68\" height=\"51\" alt=\"noelshak\" src=\"$1\"/>"]
|
108 |
// })()
|
109 |
-
...smileysMap.map(mapping => {
|
110 |
return [
|
111 |
-
new RegExp("(
|
112 |
`$1<img src="https://image.jeuxvideo.com/smileys_img/${mapping[1]}.gif" alt="${mapping[0]}"/>`
|
113 |
];
|
114 |
})
|
115 |
];
|
116 |
|
117 |
-
// console.log(jvcodeMap)
|
118 |
-
|
119 |
function injectHTML(input: string): string {
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
|
|
|
|
132 |
}
|
133 |
-
|
134 |
-
} while (false); // Repeat until no more replacements
|
135 |
|
136 |
// console.log(input)
|
137 |
-
return
|
138 |
}
|
139 |
|
140 |
function escapeHtml(unsafe: string): string {
|
|
|
1 |
import style from "./style.module.scss";
|
2 |
import {useMemo} from "preact/hooks";
|
3 |
+
import {smileysMap} from "@/utils/smileys";
|
4 |
|
5 |
// const blocks = {
|
6 |
// text: "text",
|
|
|
16 |
export function Preview(props: {
|
17 |
raw: string,
|
18 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
const html = useMemo(() => {
|
|
|
20 |
const escaped = escapeHtml(props.raw);
|
21 |
+
return injectHTML(escaped)/*.replace(/\n/g, '<br/>')*/;
|
|
|
|
|
22 |
}, [props.raw]);
|
23 |
|
24 |
return (
|
|
|
40 |
// )
|
41 |
}
|
42 |
|
43 |
+
function quoteReplacer(reg: RegExp, raw: string): string {
|
44 |
+
// return raw
|
45 |
+
const match = reg.exec(raw);
|
46 |
+
reg.lastIndex = 0;
|
47 |
+
if (!match) return raw;
|
48 |
+
|
49 |
+
const index = match.index;
|
50 |
+
const length = match[0].length;
|
51 |
+
|
52 |
+
return quoteReplacer(
|
53 |
+
reg,
|
54 |
+
raw.substring(0, index) + `<blockquote>${quoteReplacer(reg, match[0].replace(/^> ?/gm, ""))}</blockquote>` + raw.substring(index + length)
|
55 |
+
);
|
56 |
+
}
|
57 |
+
|
58 |
+
// WARNING: the order is important
|
59 |
const jvcodeMap: [RegExp, string | ((reg: RegExp, raw: string) => string)][] = [
|
60 |
+
[ // Quotes
|
61 |
+
/^>.*(?:\n>.*)*/m,
|
62 |
+
quoteReplacer
|
|
|
63 |
],
|
64 |
+
|
65 |
+
[ // Wrap text with paragraphs
|
66 |
+
/(^|>)([^<]+)($|<)/g,
|
67 |
+
"$1<p>$2</p>$3"
|
68 |
],
|
69 |
+
[ // Replace line breaks between <p> tags with <br>
|
70 |
+
/<p>\s*([\s\S]*?)\s*<\/p>/g,
|
71 |
(reg, raw) => {
|
72 |
+
// console.log(raw)
|
73 |
+
return raw.replace(reg, (_, matched) => {
|
74 |
+
// const randomId = (Math.random() + 1).toString(36).substring(2);
|
75 |
+
console.log(matched)
|
76 |
+
return `<p>${matched.replace(/\n/g, "<br/>")}</p>`;
|
77 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
}
|
79 |
],
|
80 |
[ // Spoil
|
81 |
/<spoil>(.*?)<\/spoil>/gm,
|
82 |
+
(reg, raw) => {
|
83 |
return raw.replace(reg, (_, matched) => {
|
84 |
const randomId = (Math.random() + 1).toString(36).substring(2);
|
85 |
return `<span class="bloc-spoil-jv"><input type="checkbox" id="${randomId}" class="open-spoil"><label class="barre-head" for="${randomId}"><span class="txt-spoil">Spoil</span></label><span class="contenu-spoil">${matched}</span></span>`;
|
86 |
});
|
87 |
}
|
88 |
],
|
89 |
+
[ // Stickers
|
90 |
+
// /(^|<p>| )https?:\/\/image\.noelshack\.com\/(?:fichiers|minis)([A-z0-9/\-_.]+)/gm,
|
91 |
+
/(^|<[a-z]+\/?>| )https?:\/\/image\.noelshack\.com\/(?:fichiers|minis)([A-z0-9/\-_.]+)/gm,
|
92 |
+
"$1<img class=\"sticker\" width=\"68\" height=\"51\" alt=\"sticker\" src=\"https://image.noelshack.com/minis/$2\"/>"
|
93 |
+
],
|
94 |
+
[ // Vocaroo
|
95 |
+
/(^|<[a-z]+\/?>| )https:\/\/vocaroo.com\/(.+)/gm,
|
96 |
+
"$1<div><iframe width=\"300\" height=\"60\" src=\"https://vocaroo.com/embed/$2?autoplay=0\" frameborder=\"0\" allow=\"autoplay\"></iframe></div>"
|
97 |
+
],
|
98 |
[ // Regular links
|
99 |
+
/(^|<[a-z]+\/?>| )(https?:\/\/[-a-zA-Z0-9@:%._/+~#=?&;]+)/gm,
|
100 |
"$1<a href=\"$2\" target=\"_blank\">$2</a>"
|
101 |
],
|
102 |
|
103 |
+
// TODO: embeded youtube, utiliser le titre du topic pour chercher une vidéo corespondante avec l'API search de youtube
|
104 |
+
|
105 |
// Generate regexes for smileys
|
106 |
// ...smileysMap.map((maping) => {
|
107 |
// return [new RegExp(
|
|
|
118 |
// )
|
119 |
// return [/(https?:\/\/image\.noelshack\.com\/\S+)/g, "<img width=\"68\" height=\"51\" alt=\"noelshak\" src=\"$1\"/>"]
|
120 |
// })()
|
121 |
+
...[...smileysMap].sort((a, b) => b[0].length - a[0].length).map(mapping => { // Sort smileys by length to match :-))) before :-)
|
122 |
return [
|
123 |
+
new RegExp("(<p>| )" + mapping[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), "gm"),
|
124 |
`$1<img src="https://image.jeuxvideo.com/smileys_img/${mapping[1]}.gif" alt="${mapping[0]}"/>`
|
125 |
];
|
126 |
})
|
127 |
];
|
128 |
|
|
|
|
|
129 |
function injectHTML(input: string): string {
|
130 |
+
// console.log("---------------------")
|
131 |
+
let text = input.slice()
|
132 |
+
for (const [regex, htmlOrFunc] of jvcodeMap) {
|
133 |
+
// Reset the regex @see https://stackoverflow.com/a/11477448/5912637
|
134 |
+
regex.lastIndex = 0;
|
135 |
+
|
136 |
+
// const regex = new RegExp(bbCode, 'gi');
|
137 |
+
// console.log(regex, html, input)
|
138 |
+
if (htmlOrFunc instanceof Function) {
|
139 |
+
text = htmlOrFunc(regex, text);
|
140 |
+
} else {
|
141 |
+
// console.log(regex)
|
142 |
+
// console.log(text)
|
143 |
+
text = text.replace(regex, htmlOrFunc as string);
|
144 |
}
|
145 |
+
}
|
|
|
146 |
|
147 |
// console.log(input)
|
148 |
+
return text;
|
149 |
}
|
150 |
|
151 |
function escapeHtml(unsafe: string): string {
|
src/components/preview/style.module.scss
CHANGED
@@ -2,6 +2,11 @@
|
|
2 |
//white-space: pre-line;
|
3 |
|
4 |
:global {
|
|
|
|
|
|
|
|
|
|
|
5 |
.bloc-spoil-jv {
|
6 |
//margin-bottom: 0.9375rem;
|
7 |
margin-bottom: 0;
|
|
|
2 |
//white-space: pre-line;
|
3 |
|
4 |
:global {
|
5 |
+
& .sticker {
|
6 |
+
// Force dimensions to width/height even if the image is not loaded
|
7 |
+
display: inline-block;
|
8 |
+
}
|
9 |
+
|
10 |
.bloc-spoil-jv {
|
11 |
//margin-bottom: 0.9375rem;
|
12 |
margin-bottom: 0;
|
src/components/radio/index.tsx
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import style from "./style.module.scss";
|
2 |
+
import {ChangeEvent} from "rollup";
|
3 |
+
import {JSX} from "preact";
|
4 |
+
|
5 |
+
export function Radio<Choices extends Record<string, string>>(props: {
|
6 |
+
name: string,
|
7 |
+
choices: Choices,
|
8 |
+
value: keyof Choices,
|
9 |
+
onChoose: (choice: keyof Choices) => void,
|
10 |
+
}) {
|
11 |
+
let buttons = [];
|
12 |
+
|
13 |
+
for (const [key, value] of Object.entries(props.choices)) {
|
14 |
+
console.log(props.value)
|
15 |
+
buttons.push(
|
16 |
+
<div className={style.group}>
|
17 |
+
<input
|
18 |
+
type="radio"
|
19 |
+
name={props.name}
|
20 |
+
id={key}
|
21 |
+
autoComplete="off"
|
22 |
+
value={key}
|
23 |
+
checked={props.value === key}
|
24 |
+
onClick={e => {
|
25 |
+
props.onChoose(key);
|
26 |
+
}}
|
27 |
+
/>
|
28 |
+
<label
|
29 |
+
htmlFor={key}
|
30 |
+
>
|
31 |
+
{value}
|
32 |
+
</label>
|
33 |
+
</div>
|
34 |
+
)
|
35 |
+
}
|
36 |
+
|
37 |
+
|
38 |
+
return (
|
39 |
+
<div className={style.container}>
|
40 |
+
{buttons}
|
41 |
+
</div>
|
42 |
+
);
|
43 |
+
}
|
src/components/radio/style.module.scss
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.container {
|
2 |
+
display: flex;
|
3 |
+
flex-direction: row;
|
4 |
+
}
|
5 |
+
|
6 |
+
.group {
|
7 |
+
display: inline-block;
|
8 |
+
|
9 |
+
label {
|
10 |
+
position: relative;
|
11 |
+
display: inline-flex;
|
12 |
+
justify-content: center;
|
13 |
+
align-items: center;
|
14 |
+
padding: 0 1.25rem;
|
15 |
+
margin: 0;
|
16 |
+
border: 0.0625rem solid var(--text-secondary);
|
17 |
+
border-right: none;
|
18 |
+
height: 2.25rem;
|
19 |
+
line-height: 2.25rem;
|
20 |
+
font-size: 0.9375rem;
|
21 |
+
text-decoration: none;
|
22 |
+
color: var(--text-secondary);
|
23 |
+
|
24 |
+
white-space: nowrap;
|
25 |
+
cursor: pointer;
|
26 |
+
|
27 |
+
&:hover {
|
28 |
+
border-color: var(--text-hover-secondary);
|
29 |
+
color: var(--text-hover-secondary);
|
30 |
+
}
|
31 |
+
}
|
32 |
+
|
33 |
+
&:first-child label {
|
34 |
+
border-radius: 1.125rem 0 0 1.125rem;
|
35 |
+
}
|
36 |
+
|
37 |
+
&:last-child label {
|
38 |
+
border-radius: 0 1.125rem 1.125rem 0;
|
39 |
+
border-right: 0.0625rem solid var(--text-secondary);
|
40 |
+
}
|
41 |
+
|
42 |
+
input:checked + label {
|
43 |
+
color: #fff;
|
44 |
+
background-color: var(--text-secondary);
|
45 |
+
|
46 |
+
&:hover {
|
47 |
+
border-color: var(--text-hover-secondary);
|
48 |
+
background: var(--text-hover-secondary);
|
49 |
+
color: #fff;
|
50 |
+
}
|
51 |
+
}
|
52 |
+
}
|
53 |
+
|
src/components/settings/index.tsx
CHANGED
@@ -1,40 +1,65 @@
|
|
1 |
import {Input} from "../input";
|
2 |
-
import {
|
3 |
import {Button} from "../button";
|
4 |
-
import {
|
5 |
-
import {fetchSettings, saveSettings, Settings as SettingsType} from "../../utils/settings";
|
6 |
import {Slider} from "../slider";
|
7 |
import {FormGroup} from "../form";
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
export function Settings(
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
|
|
19 |
|
20 |
return <div>
|
21 |
<form>
|
22 |
<FormGroup>
|
23 |
-
<label htmlFor="api">API</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
<Input
|
25 |
type="text"
|
26 |
-
placeholder="
|
27 |
icon={Link}
|
28 |
-
value={
|
29 |
-
onChange={(v) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
/>
|
31 |
</FormGroup>
|
32 |
<FormGroup>
|
33 |
<label for="temperature">Temperature</label>
|
34 |
<Slider
|
35 |
name="temperature"
|
36 |
-
value={
|
37 |
-
onChange={(v) =>
|
38 |
min={0.1}
|
39 |
max={2}
|
40 |
step={0.1}
|
@@ -42,25 +67,31 @@ export function Settings(props: {
|
|
42 |
</FormGroup>
|
43 |
<div>
|
44 |
<Button
|
45 |
-
onClick={
|
46 |
-
props.resetApp()
|
47 |
-
}}
|
48 |
secondary={true}
|
49 |
title="Tout réinitialiser"
|
50 |
>
|
51 |
-
|
52 |
</Button>
|
53 |
</div>
|
54 |
<br/>
|
55 |
<div>
|
56 |
-
<Button
|
57 |
-
onClick={() => {
|
58 |
-
history.go(-1)
|
59 |
-
}}
|
60 |
-
>
|
61 |
Retour
|
62 |
</Button>
|
63 |
</div>
|
64 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
</div>
|
66 |
}
|
|
|
1 |
import {Input} from "../input";
|
2 |
+
import {X, Link, Key} from "preact-feather";
|
3 |
import {Button} from "../button";
|
4 |
+
import {useContext} from "preact/hooks";
|
|
|
5 |
import {Slider} from "../slider";
|
6 |
import {FormGroup} from "../form";
|
7 |
+
import {settingsCtx, Settings as SettingsType} from "@/contexts/settings";
|
8 |
+
import {routeCtx} from "@/contexts/route";
|
9 |
+
import {topicsCtx} from "@/contexts/topics";
|
10 |
+
import style from "./style.module.scss";
|
11 |
+
import {logCtx} from "@/contexts/log";
|
12 |
+
import {Radio} from "@/components/radio";
|
13 |
|
14 |
+
export function Settings() {
|
15 |
+
const [, , routeActions] = useContext(routeCtx);
|
16 |
+
const [settings, setSettings, settingsActions] = useContext(settingsCtx);
|
17 |
+
const [, , topicsActions] = useContext(topicsCtx);
|
18 |
+
const [log, , logActions] = useContext(logCtx);
|
19 |
+
|
20 |
+
const resetApp = () => {
|
21 |
+
settingsActions.reset();
|
22 |
+
topicsActions.reset();
|
23 |
+
logActions.reset();
|
24 |
+
}
|
25 |
|
26 |
return <div>
|
27 |
<form>
|
28 |
<FormGroup>
|
29 |
+
<label htmlFor="api">Type d'API</label>
|
30 |
+
<Radio<{[key in SettingsType["apiType"]]: string}>
|
31 |
+
name="apiType"
|
32 |
+
choices={{"ooba": "Oobabooga", "beam": "Beam Cloud"}}
|
33 |
+
value={settings.apiType}
|
34 |
+
onChoose={(choice) => setSettings({...settings, apiType: choice})}
|
35 |
+
/>
|
36 |
+
</FormGroup>
|
37 |
+
<FormGroup>
|
38 |
+
<label htmlFor="api">Url d'API</label>
|
39 |
<Input
|
40 |
type="text"
|
41 |
+
placeholder={settings.apiType === "beam" ? "Ex: https://jvcgpt-d692de2-v1.app.beam.cloud/stream" : "Ex: https://ouruq7zepnehg2-5000.proxy.runpod.net/"}
|
42 |
icon={Link}
|
43 |
+
value={settings.apiURL}
|
44 |
+
onChange={(v) => setSettings({...settings, apiURL: v as string})}
|
45 |
+
/>
|
46 |
+
</FormGroup>
|
47 |
+
<FormGroup>
|
48 |
+
<label htmlFor="api">Clé d'API</label>
|
49 |
+
<Input
|
50 |
+
type="password"
|
51 |
+
placeholder="Ex: zyCH5svVE-qXv_J34ZLGc0vKQaGbYf_7YTbl2VKaAIqgwa_-qCeA=="
|
52 |
+
icon={Key}
|
53 |
+
value={settings.apiKey}
|
54 |
+
onChange={(v) => setSettings({...settings, apiKey: v as string})}
|
55 |
/>
|
56 |
</FormGroup>
|
57 |
<FormGroup>
|
58 |
<label for="temperature">Temperature</label>
|
59 |
<Slider
|
60 |
name="temperature"
|
61 |
+
value={settings.temperature}
|
62 |
+
onChange={(v) => setSettings({...settings, temperature: v})}
|
63 |
min={0.1}
|
64 |
max={2}
|
65 |
step={0.1}
|
|
|
67 |
</FormGroup>
|
68 |
<div>
|
69 |
<Button
|
70 |
+
onClick={resetApp}
|
|
|
|
|
71 |
secondary={true}
|
72 |
title="Tout réinitialiser"
|
73 |
>
|
74 |
+
Tout réinitialiser
|
75 |
</Button>
|
76 |
</div>
|
77 |
<br/>
|
78 |
<div>
|
79 |
+
<Button onClick={routeActions.goBack}>
|
|
|
|
|
|
|
|
|
80 |
Retour
|
81 |
</Button>
|
82 |
</div>
|
83 |
</form>
|
84 |
+
<hr/>
|
85 |
+
<div>
|
86 |
+
<h2>Log</h2>
|
87 |
+
<pre className={style.log}>
|
88 |
+
{log}
|
89 |
+
</pre>
|
90 |
+
<div>
|
91 |
+
<Button onClick={logActions.reset} title="Vider le log">
|
92 |
+
<X/>
|
93 |
+
</Button>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
</div>
|
97 |
}
|
src/components/settings/style.module.scss
CHANGED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.log {
|
2 |
+
border: 0.0625rem solid var(--border-color);
|
3 |
+
border-radius: 0.25rem;
|
4 |
+
display: block;
|
5 |
+
font-size: inherit;
|
6 |
+
line-height: 1.42857;
|
7 |
+
margin: 0 0 1.5rem 0;
|
8 |
+
padding: 0.5625rem;
|
9 |
+
word-break: break-all;
|
10 |
+
word-wrap: break-word;
|
11 |
+
overflow: auto;
|
12 |
+
overflow-y: scroll;
|
13 |
+
min-height: 5rem;
|
14 |
+
max-height: 30rem;
|
15 |
+
}
|
src/components/spinner/style.module.scss
CHANGED
@@ -1,14 +1,18 @@
|
|
1 |
.spinnerSquare {
|
|
|
|
|
|
|
|
|
2 |
display: flex;
|
3 |
flex-direction: row;
|
4 |
-
width:
|
5 |
-
height:
|
6 |
|
7 |
> div {
|
8 |
-
width:
|
9 |
-
height:
|
10 |
margin: auto auto;
|
11 |
-
border-radius:
|
12 |
}
|
13 |
}
|
14 |
|
@@ -26,21 +30,21 @@
|
|
26 |
|
27 |
@keyframes square-anim {
|
28 |
0% {
|
29 |
-
height:
|
30 |
background-color: var(--text-secondary);
|
31 |
}
|
32 |
20% {
|
33 |
-
height:
|
34 |
}
|
35 |
40% {
|
36 |
-
height:
|
37 |
background-color: var(--text-hover-secondary);
|
38 |
}
|
39 |
80% {
|
40 |
-
height:
|
41 |
}
|
42 |
100% {
|
43 |
-
height:
|
44 |
background-color: var(--text-secondary);
|
45 |
}
|
46 |
}
|
|
|
1 |
.spinnerSquare {
|
2 |
+
--height: 40px;
|
3 |
+
--width: 50px;
|
4 |
+
--small-height: calc(var(--height) / 1.5);
|
5 |
+
|
6 |
display: flex;
|
7 |
flex-direction: row;
|
8 |
+
width: var(--width);
|
9 |
+
height: var(--height);
|
10 |
|
11 |
> div {
|
12 |
+
width: calc(var(--width) / 5);
|
13 |
+
height: var(--small-height);
|
14 |
margin: auto auto;
|
15 |
+
border-radius: 2px;
|
16 |
}
|
17 |
}
|
18 |
|
|
|
30 |
|
31 |
@keyframes square-anim {
|
32 |
0% {
|
33 |
+
height: var(--small-height);
|
34 |
background-color: var(--text-secondary);
|
35 |
}
|
36 |
20% {
|
37 |
+
height: var(--small-height);
|
38 |
}
|
39 |
40% {
|
40 |
+
height: var(--height);
|
41 |
background-color: var(--text-hover-secondary);
|
42 |
}
|
43 |
80% {
|
44 |
+
height: var(--small-height);
|
45 |
}
|
46 |
100% {
|
47 |
+
height: var(--small-height);
|
48 |
background-color: var(--text-secondary);
|
49 |
}
|
50 |
}
|
src/components/topic/index.tsx
CHANGED
@@ -1,24 +1,78 @@
|
|
1 |
-
import {Post as PostType, Topic as TopicType} from "
|
2 |
import style from "./style.module.scss";
|
3 |
import {Preview} from "../preview";
|
4 |
-
import {iso8601ToFrench} from "
|
5 |
import {FormGroup} from "../form";
|
6 |
import {Slider} from "../slider";
|
7 |
import {Button} from "../button";
|
8 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
export function Topic(props: {
|
11 |
topic: TopicType,
|
12 |
-
|
13 |
-
|
14 |
-
addPosts: (topicId: string, postsCount: number) => Promise<void>,
|
15 |
-
pendingGeneration: boolean,
|
16 |
}) {
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
return (
|
20 |
<div>
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
<div>
|
23 |
<h2>Ajout de posts</h2>
|
24 |
<div className={style.generationSettings}>
|
@@ -26,10 +80,8 @@ export function Topic(props: {
|
|
26 |
<label htmlFor="postCount">Nombre de posts</label>
|
27 |
<Slider
|
28 |
name="postCount"
|
29 |
-
value={
|
30 |
-
|
31 |
-
// onChange={setGenerationPostCount}
|
32 |
-
onChange={(v) => props.setSettings({...props.settings, postCount: v})}
|
33 |
min={1}
|
34 |
max={10}
|
35 |
step={1}
|
@@ -37,20 +89,33 @@ export function Topic(props: {
|
|
37 |
</FormGroup>
|
38 |
</div>
|
39 |
<Button
|
40 |
-
onClick={() =>
|
41 |
secondary={true}
|
42 |
-
loading={
|
43 |
>
|
44 |
-
Générer
|
45 |
</Button>
|
46 |
</div>
|
47 |
<hr/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
</div>
|
49 |
)
|
50 |
}
|
51 |
|
52 |
function Post(props: {
|
53 |
-
post: PostType
|
|
|
|
|
|
|
54 |
}) {
|
55 |
return (
|
56 |
<div className={style.post}>
|
@@ -58,8 +123,17 @@ function Post(props: {
|
|
58 |
<img src="https://image.jeuxvideo.com/avatar-sm/default.jpg" className={style.avatar} alt="ahi"/>
|
59 |
<div className={style.user}>{props.post.user}</div>
|
60 |
<div className={style.date}>{iso8601ToFrench(props.post.date)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
</div>
|
62 |
<Preview raw={props.post.content}/>
|
|
|
63 |
</div>
|
64 |
)
|
65 |
}
|
|
|
1 |
+
import {Post as PostType, Topic as TopicType, topicsCtx} from "@/contexts/topics";
|
2 |
import style from "./style.module.scss";
|
3 |
import {Preview} from "../preview";
|
4 |
+
import {iso8601ToFrench} from "@/utils/dates";
|
5 |
import {FormGroup} from "../form";
|
6 |
import {Slider} from "../slider";
|
7 |
import {Button} from "../button";
|
8 |
+
import {settingsCtx} from "@/contexts/settings";
|
9 |
+
import {useContext, useState, useMemo} from "preact/hooks";
|
10 |
+
import {Wysiwyg} from "@/components/wysiwyg";
|
11 |
+
import {
|
12 |
+
ChevronRight,
|
13 |
+
X,
|
14 |
+
} from "preact-feather";
|
15 |
+
import cn from "classnames";
|
16 |
+
import {Pagination} from "@/components/pagination";
|
17 |
+
import {Spinner} from "@/components/spinner";
|
18 |
+
import {logCtx} from "@/contexts/log";
|
19 |
+
|
20 |
+
const postsPerPage = 10;
|
21 |
|
22 |
export function Topic(props: {
|
23 |
topic: TopicType,
|
24 |
+
page: number,
|
25 |
+
setPage: (newPage: number) => void,
|
|
|
|
|
26 |
}) {
|
27 |
+
/**
|
28 |
+
* TODO
|
29 |
+
* prendre la générationDate du premier post et remplacer toutes la dates (IA) avec celle-ci en calculant le delta en en l'appliquant à chaque post
|
30 |
+
* Pour il faut remplacer la date des citations en prenant en compte le delta
|
31 |
+
*
|
32 |
+
*/
|
33 |
+
const [topicsContext, , topicsActions] = useContext(topicsCtx);
|
34 |
+
const [settings, setSettings] = useContext(settingsCtx);
|
35 |
+
const [, , logActions] = useContext(logCtx);
|
36 |
+
const [wysiwygText, setWysiwygText] = useState("");
|
37 |
+
|
38 |
+
const addQuote = (post: PostType) => {
|
39 |
+
setWysiwygText((wysiwygText.length > 1 ? `${wysiwygText}\n\n` : "") + post.content.split("\n").map(l => `> ${l}`).join("\n"))
|
40 |
+
}
|
41 |
+
|
42 |
+
const postsSlice = useMemo(
|
43 |
+
() => props.topic.posts.slice(props.page * postsPerPage, (props.page + 1) * postsPerPage),
|
44 |
+
[props.page, props.topic.posts]
|
45 |
+
);
|
46 |
+
|
47 |
+
const pendingGeneration = topicsContext.generation === "pending";
|
48 |
+
|
49 |
+
const pagination = (
|
50 |
+
<Pagination
|
51 |
+
pageCount={Math.ceil(props.topic.posts.length / postsPerPage)}
|
52 |
+
page={props.page}
|
53 |
+
setPage={props.setPage}
|
54 |
+
/>
|
55 |
+
);
|
56 |
|
57 |
return (
|
58 |
<div>
|
59 |
+
<div className={style.wrapper}>
|
60 |
+
{pagination}
|
61 |
+
<div className={style.postsContainer}>
|
62 |
+
{postsSlice.map((post, index) => {
|
63 |
+
const realIndex = postsPerPage * props.page + index;
|
64 |
+
return (
|
65 |
+
<Post
|
66 |
+
post={post}
|
67 |
+
quote={() => addQuote(post)}
|
68 |
+
remove={() => topicsActions.deletePost(props.topic.id, realIndex)}
|
69 |
+
loading={pendingGeneration && realIndex >= props.topic.posts.length - 1}
|
70 |
+
/>
|
71 |
+
)
|
72 |
+
})}
|
73 |
+
</div>
|
74 |
+
{pagination}
|
75 |
+
</div>
|
76 |
<div>
|
77 |
<h2>Ajout de posts</h2>
|
78 |
<div className={style.generationSettings}>
|
|
|
80 |
<label htmlFor="postCount">Nombre de posts</label>
|
81 |
<Slider
|
82 |
name="postCount"
|
83 |
+
value={settings.postCount}
|
84 |
+
onChange={(v) => setSettings({...settings, postCount: v})}
|
|
|
|
|
85 |
min={1}
|
86 |
max={10}
|
87 |
step={1}
|
|
|
89 |
</FormGroup>
|
90 |
</div>
|
91 |
<Button
|
92 |
+
onClick={() => topicsActions.generatePosts(settings, logActions.log, props.topic)}
|
93 |
secondary={true}
|
94 |
+
loading={pendingGeneration}
|
95 |
>
|
96 |
+
{pendingGeneration ? "Génération en cours…" : "Générer"}
|
97 |
</Button>
|
98 |
</div>
|
99 |
<hr/>
|
100 |
+
<div>
|
101 |
+
<Wysiwyg
|
102 |
+
showTitle={false}
|
103 |
+
onSubmit={(user, title, text) => {
|
104 |
+
topicsActions.addPost(props.topic.id, user, text)
|
105 |
+
}}
|
106 |
+
text={wysiwygText}
|
107 |
+
setText={setWysiwygText}
|
108 |
+
/>
|
109 |
+
</div>
|
110 |
</div>
|
111 |
)
|
112 |
}
|
113 |
|
114 |
function Post(props: {
|
115 |
+
post: PostType,
|
116 |
+
quote: () => void,
|
117 |
+
remove: () => void,
|
118 |
+
loading: boolean,
|
119 |
}) {
|
120 |
return (
|
121 |
<div className={style.post}>
|
|
|
123 |
<img src="https://image.jeuxvideo.com/avatar-sm/default.jpg" className={style.avatar} alt="ahi"/>
|
124 |
<div className={style.user}>{props.post.user}</div>
|
125 |
<div className={style.date}>{iso8601ToFrench(props.post.date)}</div>
|
126 |
+
<div className={style.actions}>
|
127 |
+
<div className={cn(style.action, style.actionDanger)} title="Supprimer" onClick={props.remove}>
|
128 |
+
<X size={16} style={{right: 0.6, top: 1.2}}/>
|
129 |
+
</div>
|
130 |
+
<div className={style.action} title="Citer" onClick={props.quote}>
|
131 |
+
<ChevronRight size={16} style={{left: 1.2}}/>
|
132 |
+
</div>
|
133 |
+
</div>
|
134 |
</div>
|
135 |
<Preview raw={props.post.content}/>
|
136 |
+
{props.loading ? <Spinner className={style.spinner}/> : null}
|
137 |
</div>
|
138 |
)
|
139 |
}
|
src/components/topic/style.module.scss
CHANGED
@@ -1,3 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
.post {
|
2 |
margin-bottom: 0.9375rem;
|
3 |
font-size: .9375rem;
|
@@ -15,10 +24,10 @@
|
|
15 |
margin-bottom: 0.75rem;
|
16 |
display: grid;
|
17 |
grid-template-areas:
|
18 |
-
"avatar user"
|
19 |
-
"avatar date";
|
20 |
grid-template-rows: 1.5rem 1.5rem;
|
21 |
-
grid-template-columns: auto 1fr;
|
22 |
column-gap: .625rem;
|
23 |
}
|
24 |
|
@@ -45,6 +54,41 @@
|
|
45 |
font-size: .8125rem;
|
46 |
}
|
47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
.generationSettings {
|
49 |
display: grid;
|
50 |
grid-template-columns: 1fr 1fr;
|
|
|
1 |
+
.wrapper {
|
2 |
+
margin-bottom: 1.5rem;
|
3 |
+
}
|
4 |
+
|
5 |
+
.postsContainer {
|
6 |
+
margin-top: 1.5rem;
|
7 |
+
margin-bottom: 1.5rem;
|
8 |
+
}
|
9 |
+
|
10 |
.post {
|
11 |
margin-bottom: 0.9375rem;
|
12 |
font-size: .9375rem;
|
|
|
24 |
margin-bottom: 0.75rem;
|
25 |
display: grid;
|
26 |
grid-template-areas:
|
27 |
+
"avatar user actions"
|
28 |
+
"avatar date actions";
|
29 |
grid-template-rows: 1.5rem 1.5rem;
|
30 |
+
grid-template-columns: auto 1fr auto;
|
31 |
column-gap: .625rem;
|
32 |
}
|
33 |
|
|
|
54 |
font-size: .8125rem;
|
55 |
}
|
56 |
|
57 |
+
.actions {
|
58 |
+
grid-area: actions;
|
59 |
+
display: flex;
|
60 |
+
gap: 0.2rem;
|
61 |
+
}
|
62 |
+
|
63 |
+
.action {
|
64 |
+
border-radius: 0.25rem;
|
65 |
+
background-color: #515458;
|
66 |
+
padding: 0.05rem;
|
67 |
+
cursor: pointer;
|
68 |
+
width: 16px;
|
69 |
+
height: 16px;
|
70 |
+
|
71 |
+
svg {
|
72 |
+
display: block;
|
73 |
+
position: relative;
|
74 |
+
//margin-bottom: 1px;
|
75 |
+
//margin-left: 1px;
|
76 |
+
//left: 1px;
|
77 |
+
top: 1px;
|
78 |
+
margin: auto;
|
79 |
+
height: calc(100% - 2px);
|
80 |
+
width: calc(100% - 2px);
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
.actionDanger {
|
85 |
+
background-color: var(--text-danger);
|
86 |
+
}
|
87 |
+
|
88 |
+
.spinner {
|
89 |
+
margin: auto;
|
90 |
+
}
|
91 |
+
|
92 |
.generationSettings {
|
93 |
display: grid;
|
94 |
grid-template-columns: 1fr 1fr;
|
src/components/topics/index.tsx
CHANGED
@@ -1,59 +1,87 @@
|
|
1 |
-
import {Topic} from "
|
2 |
import {Layout} from "../layout";
|
3 |
import {Spinner} from "../spinner";
|
4 |
import style from "./style.module.scss";
|
5 |
-
import {Route, routes, RouteSetter} from "../../utils/route";
|
6 |
import {Button} from "../button";
|
7 |
import {Input} from "../input";
|
8 |
-
import {
|
9 |
-
import {
|
10 |
-
import {
|
11 |
-
import {frenchToIso8601, iso8601ToFrench} from "../../utils/dates";
|
12 |
import {FormGroup} from "../form";
|
13 |
import {Slider} from "../slider";
|
14 |
import cn from "classnames";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
export function Topics(props: {
|
17 |
-
|
18 |
-
|
19 |
-
settings: Settings,
|
20 |
-
setSettings: (settings: SettingsType) => void,
|
21 |
-
generateTopic: (postCount: number) => Promise<void>,
|
22 |
-
pendingGeneration: boolean,
|
23 |
-
latestGeneratedTopicId: string | null,
|
24 |
}) {
|
25 |
-
|
|
|
|
|
|
|
26 |
|
27 |
-
|
28 |
-
if
|
29 |
-
|
30 |
}
|
|
|
31 |
|
32 |
-
|
|
|
33 |
if (topicA.posts.length < 1 || topicB.posts.length < 1) {
|
34 |
return 0;
|
35 |
}
|
36 |
return topicB.posts[topicB.posts.length - 1].date.localeCompare(topicA.posts[topicA.posts.length - 1].date);
|
37 |
});
|
38 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
|
40 |
return (
|
41 |
<div>
|
42 |
-
{
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
<div>
|
49 |
<h2>Nouveau sujet</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
<div className={style.generationSettings}>
|
51 |
<FormGroup>
|
52 |
<label for="postCount">Nombre de posts</label>
|
53 |
<Slider
|
54 |
name="postCount"
|
55 |
-
value={
|
56 |
-
onChange={(v) =>
|
57 |
min={1}
|
58 |
max={10}
|
59 |
step={1}
|
@@ -61,22 +89,65 @@ export function Topics(props: {
|
|
61 |
</FormGroup>
|
62 |
</div>
|
63 |
<Button
|
64 |
-
onClick={() =>
|
|
|
|
|
65 |
secondary={true}
|
66 |
-
loading={
|
67 |
>
|
68 |
-
Générer
|
69 |
</Button>
|
70 |
</div>
|
71 |
<hr/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
</div>
|
73 |
)
|
74 |
}
|
75 |
|
76 |
function List(props: {
|
77 |
topics: Topic[],
|
78 |
-
|
79 |
-
latestGeneratedTopicId: string | null,
|
80 |
}) {
|
81 |
return (
|
82 |
<ul className={style.list}>
|
@@ -85,23 +156,64 @@ function List(props: {
|
|
85 |
<span>Auteur</span>
|
86 |
<span>NB</span>
|
87 |
<span>Dernier msg</span>
|
|
|
88 |
</li>
|
89 |
-
{props.topics.length < 1 && <li><span>Aucun sujet</span><span
|
90 |
-
{props.topics.map(topic =>
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
<span>
|
93 |
-
<a href=
|
94 |
e.preventDefault();
|
95 |
-
|
96 |
}}>
|
97 |
{topic.title}
|
98 |
</a>
|
99 |
</span>
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
106 |
)
|
107 |
}
|
|
|
1 |
+
import {Topic, topicsCtx} from "@/contexts/topics";
|
2 |
import {Layout} from "../layout";
|
3 |
import {Spinner} from "../spinner";
|
4 |
import style from "./style.module.scss";
|
|
|
5 |
import {Button} from "../button";
|
6 |
import {Input} from "../input";
|
7 |
+
import {Clipboard, Trash2} from "preact-feather";
|
8 |
+
import {useContext, useEffect, useMemo, useRef, useState} from "preact/hooks";
|
9 |
+
import {frenchToIso8601, iso8601ToFrench} from "@/utils/dates";
|
|
|
10 |
import {FormGroup} from "../form";
|
11 |
import {Slider} from "../slider";
|
12 |
import cn from "classnames";
|
13 |
+
import {routeCtx, routes} from "@/contexts/route";
|
14 |
+
import {settingsCtx} from "@/contexts/settings";
|
15 |
+
import {Wysiwyg} from "@/components/wysiwyg";
|
16 |
+
import {Alert} from "@/components/alert";
|
17 |
+
import {logCtx} from "@/contexts/log";
|
18 |
+
import {Pagination} from "@/components/pagination";
|
19 |
+
|
20 |
+
const topicsPerPage = 10;
|
21 |
|
22 |
export function Topics(props: {
|
23 |
+
page: number,
|
24 |
+
setPage: (newPage: number) => void,
|
|
|
|
|
|
|
|
|
|
|
25 |
}) {
|
26 |
+
const [importContent, setImportContent] = useState("");
|
27 |
+
const [topicsContext, setTopicsContext, topicsActions] = useContext(topicsCtx);
|
28 |
+
const [, , logActions] = useContext(logCtx);
|
29 |
+
const [settings, setSettings] = useContext(settingsCtx);
|
30 |
|
31 |
+
useEffect(() => {
|
32 |
+
if((topicsPerPage * props.page) >= topicsContext.topics.length) {
|
33 |
+
props.setPage(Math.ceil(topicsContext.topics.length / topicsPerPage)/* - 1*/);
|
34 |
}
|
35 |
+
}, [topicsContext.topics.length])
|
36 |
|
37 |
+
const sortedTopics = useMemo(() => {
|
38 |
+
return [...topicsContext.topics].sort((topicA, topicB) => {
|
39 |
if (topicA.posts.length < 1 || topicB.posts.length < 1) {
|
40 |
return 0;
|
41 |
}
|
42 |
return topicB.posts[topicB.posts.length - 1].date.localeCompare(topicA.posts[topicA.posts.length - 1].date);
|
43 |
});
|
44 |
+
}, [topicsContext.topics]);
|
45 |
+
|
46 |
+
const sortedTopicsSlice = useMemo(
|
47 |
+
() => sortedTopics.slice(props.page * topicsPerPage, (props.page + 1) * topicsPerPage),
|
48 |
+
[props.page, sortedTopics]
|
49 |
+
);
|
50 |
+
|
51 |
+
const pendingGeneration = topicsContext.generation === "pending";
|
52 |
|
53 |
return (
|
54 |
<div>
|
55 |
+
<div className={style.listWrapper}>
|
56 |
+
<Pagination
|
57 |
+
pageCount={Math.ceil(sortedTopics.length / topicsPerPage)}
|
58 |
+
page={props.page}
|
59 |
+
setPage={props.setPage}
|
60 |
+
/>
|
61 |
+
<List topics={sortedTopicsSlice}/>
|
62 |
+
<Pagination
|
63 |
+
pageCount={Math.ceil(sortedTopics.length / topicsPerPage)}
|
64 |
+
page={props.page}
|
65 |
+
setPage={props.setPage}
|
66 |
+
/>
|
67 |
+
</div>
|
68 |
<div>
|
69 |
<h2>Nouveau sujet</h2>
|
70 |
+
<Alert
|
71 |
+
lines={
|
72 |
+
topicsContext.generation === "error" ? [
|
73 |
+
"Erreur lors de la génération du topic",
|
74 |
+
"Veuillez regarder le log pour plus d'informations"
|
75 |
+
] : []
|
76 |
+
}
|
77 |
+
/>
|
78 |
<div className={style.generationSettings}>
|
79 |
<FormGroup>
|
80 |
<label for="postCount">Nombre de posts</label>
|
81 |
<Slider
|
82 |
name="postCount"
|
83 |
+
value={settings.postCount}
|
84 |
+
onChange={(v) => setSettings({...settings, postCount: v})}
|
85 |
min={1}
|
86 |
max={10}
|
87 |
step={1}
|
|
|
89 |
</FormGroup>
|
90 |
</div>
|
91 |
<Button
|
92 |
+
onClick={() => {
|
93 |
+
topicsActions.generateTopic(settings, logActions.log)
|
94 |
+
}}
|
95 |
secondary={true}
|
96 |
+
loading={pendingGeneration}
|
97 |
>
|
98 |
+
{pendingGeneration ? "Génération en cours…" : "Générer"}
|
99 |
</Button>
|
100 |
</div>
|
101 |
<hr/>
|
102 |
+
<div>
|
103 |
+
<Wysiwyg
|
104 |
+
showTitle={true}
|
105 |
+
onSubmit={(user, title, text) => {
|
106 |
+
// setLastAddedTopicId(topicsActions.addTopic(user, title, text));
|
107 |
+
topicsActions.addTopic(user, title, text)
|
108 |
+
props.setPage(0);
|
109 |
+
window.scrollTo({top: 0, behavior: 'smooth'});
|
110 |
+
}}
|
111 |
+
disabled={pendingGeneration}
|
112 |
+
/>
|
113 |
+
</div>
|
114 |
+
<hr/>
|
115 |
+
<div>
|
116 |
+
<Alert
|
117 |
+
lines={
|
118 |
+
topicsContext.import === "error" ? [
|
119 |
+
"Erreur lors de l'importation du sujet",
|
120 |
+
] : []
|
121 |
+
}
|
122 |
+
/>
|
123 |
+
<div className={style.import}>
|
124 |
+
<textarea
|
125 |
+
placeholder="Importer un sujet…"
|
126 |
+
name="import"
|
127 |
+
id="import"
|
128 |
+
cols="30"
|
129 |
+
rows="10"
|
130 |
+
value={importContent}
|
131 |
+
onInput={(e) => setImportContent((e.target as HTMLTextAreaElement).value)}
|
132 |
+
/>
|
133 |
+
</div>
|
134 |
+
<Button
|
135 |
+
onClick={() => {
|
136 |
+
topicsActions.importTopic(importContent);
|
137 |
+
setImportContent("");
|
138 |
+
}}
|
139 |
+
secondary={true}
|
140 |
+
>
|
141 |
+
Importer
|
142 |
+
</Button>
|
143 |
+
</div>
|
144 |
</div>
|
145 |
)
|
146 |
}
|
147 |
|
148 |
function List(props: {
|
149 |
topics: Topic[],
|
150 |
+
// latestGeneratedTopicId: string | null,
|
|
|
151 |
}) {
|
152 |
return (
|
153 |
<ul className={style.list}>
|
|
|
156 |
<span>Auteur</span>
|
157 |
<span>NB</span>
|
158 |
<span>Dernier msg</span>
|
159 |
+
<span></span>
|
160 |
</li>
|
161 |
+
{props.topics.length < 1 && <li><span>Aucun sujet</span><span/><span/><span/><span/></li>}
|
162 |
+
{props.topics.map(topic => <TopicElement topic={topic}/>)}
|
163 |
+
</ul>
|
164 |
+
)
|
165 |
+
}
|
166 |
+
|
167 |
+
function TopicElement(props: {
|
168 |
+
topic: Topic;
|
169 |
+
}) {
|
170 |
+
const topic = props.topic;
|
171 |
+
const [, setRoute] = useContext(routeCtx);
|
172 |
+
const [, , topicsActions] = useContext(topicsCtx);
|
173 |
+
|
174 |
+
let isRecent: boolean = false;
|
175 |
+
if (topic.posts.length > 0) {
|
176 |
+
const firstPost = topic.posts[0];
|
177 |
+
const dateDelta = new Date() - new Date(firstPost.generationDate || firstPost.date); // difference in ms
|
178 |
+
|
179 |
+
// If the topic is more recent than 3 sec
|
180 |
+
if (dateDelta < 3000) {
|
181 |
+
isRecent = true;
|
182 |
+
}
|
183 |
+
}
|
184 |
+
|
185 |
+
return (
|
186 |
+
<li className={cn({[style.highlight]: isRecent})}>
|
187 |
<span>
|
188 |
+
<a href={routes.topic(topic.id, 1).location} onClick={(e) => {
|
189 |
e.preventDefault();
|
190 |
+
setRoute(routes.topic(topic.id, 1));
|
191 |
}}>
|
192 |
{topic.title}
|
193 |
</a>
|
194 |
</span>
|
195 |
+
<span>{topic.posts[0].user}</span>
|
196 |
+
<span>{topic.posts.length}</span>
|
197 |
+
<span>{iso8601ToFrench(topic.posts[topic.posts.length - 1].date)}</span>
|
198 |
+
<span>
|
199 |
+
<span title="Copier le sujet">
|
200 |
+
<Clipboard
|
201 |
+
size={16}
|
202 |
+
className={style.clipboard}
|
203 |
+
onClick={() => {
|
204 |
+
const json = JSON.stringify(topic);
|
205 |
+
navigator.clipboard.writeText(json);
|
206 |
+
}}
|
207 |
+
/>
|
208 |
+
</span>
|
209 |
+
<span title="Supprimer le sujet">
|
210 |
+
<Trash2
|
211 |
+
size={16}
|
212 |
+
className={style.trash}
|
213 |
+
onClick={() => topicsActions.deleteTopic(topic.id)}
|
214 |
+
/>
|
215 |
+
</span>
|
216 |
+
</span>
|
217 |
+
</li>
|
218 |
)
|
219 |
}
|
src/components/topics/style.module.scss
CHANGED
@@ -2,9 +2,13 @@
|
|
2 |
margin: 5rem auto;
|
3 |
}
|
4 |
|
|
|
|
|
|
|
|
|
5 |
.list {
|
6 |
display: grid;
|
7 |
-
grid-template-columns: 10fr 3fr 1fr 3fr;
|
8 |
|
9 |
list-style-type: none;
|
10 |
//margin: 0;
|
@@ -15,6 +19,7 @@
|
|
15 |
width: 100%;
|
16 |
padding: 0;
|
17 |
overflow: hidden;
|
|
|
18 |
margin-bottom: 1.5rem;
|
19 |
|
20 |
> li {
|
@@ -27,6 +32,11 @@
|
|
27 |
font-weight: var(--topic-font-weight);
|
28 |
font-size: var(--topic-font-size);
|
29 |
padding: var(--topic-gap);
|
|
|
|
|
|
|
|
|
|
|
30 |
|
31 |
a {
|
32 |
overflow: hidden;
|
@@ -47,18 +57,29 @@
|
|
47 |
|
48 |
&:nth-child(even) {
|
49 |
> span {
|
50 |
-
background: var(--block-even-bg-color);
|
51 |
}
|
52 |
}
|
53 |
|
54 |
&.highlight {
|
55 |
> span {
|
56 |
-
background: var(--block-highlighted-bg-color);
|
57 |
}
|
58 |
}
|
59 |
}
|
60 |
}
|
61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
.head {
|
63 |
> span {
|
64 |
text-transform: uppercase;
|
@@ -74,4 +95,30 @@
|
|
74 |
//@media screen and (min-width: 600px) {
|
75 |
// grid-template-columns: 1fr 1fr;
|
76 |
//}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
}
|
|
|
2 |
margin: 5rem auto;
|
3 |
}
|
4 |
|
5 |
+
.listWrapper {
|
6 |
+
margin-bottom: 1.5rem;
|
7 |
+
}
|
8 |
+
|
9 |
.list {
|
10 |
display: grid;
|
11 |
+
grid-template-columns: 10fr 3fr 1fr 3fr 0.5fr;
|
12 |
|
13 |
list-style-type: none;
|
14 |
//margin: 0;
|
|
|
19 |
width: 100%;
|
20 |
padding: 0;
|
21 |
overflow: hidden;
|
22 |
+
margin-top: 1.5rem;
|
23 |
margin-bottom: 1.5rem;
|
24 |
|
25 |
> li {
|
|
|
32 |
font-weight: var(--topic-font-weight);
|
33 |
font-size: var(--topic-font-size);
|
34 |
padding: var(--topic-gap);
|
35 |
+
height: 100%;
|
36 |
+
display: flex;
|
37 |
+
align-items: center;
|
38 |
+
background-color: transparent;
|
39 |
+
transition: background-color .2s ease-in-out;
|
40 |
|
41 |
a {
|
42 |
overflow: hidden;
|
|
|
57 |
|
58 |
&:nth-child(even) {
|
59 |
> span {
|
60 |
+
background-color: var(--block-even-bg-color);
|
61 |
}
|
62 |
}
|
63 |
|
64 |
&.highlight {
|
65 |
> span {
|
66 |
+
background-color: var(--block-highlighted-bg-color);
|
67 |
}
|
68 |
}
|
69 |
}
|
70 |
}
|
71 |
|
72 |
+
.clipboard {
|
73 |
+
cursor: pointer;
|
74 |
+
color: var(--link-color);
|
75 |
+
margin-right: 0.25rem;
|
76 |
+
}
|
77 |
+
|
78 |
+
.trash {
|
79 |
+
cursor: pointer;
|
80 |
+
color: var(--text-danger);
|
81 |
+
}
|
82 |
+
|
83 |
.head {
|
84 |
> span {
|
85 |
text-transform: uppercase;
|
|
|
95 |
//@media screen and (min-width: 600px) {
|
96 |
// grid-template-columns: 1fr 1fr;
|
97 |
//}
|
98 |
+
}
|
99 |
+
|
100 |
+
.import {
|
101 |
+
background: var(--input-bg-color);
|
102 |
+
overflow: hidden;
|
103 |
+
border: 0.0625rem solid var(--input-border-color);
|
104 |
+
border-radius: 0.25rem;
|
105 |
+
margin-bottom: 0.9375rem;
|
106 |
+
|
107 |
+
textarea {
|
108 |
+
display: block;
|
109 |
+
width: 100%;
|
110 |
+
height: 10rem;
|
111 |
+
max-width: 100%;
|
112 |
+
min-width: 100%;
|
113 |
+
border: 0 solid rgba(0, 0, 0, 0);
|
114 |
+
padding: 0.625rem;
|
115 |
+
background-color: rgba(0, 0, 0, 0);
|
116 |
+
color: var(--input-text-color);
|
117 |
+
min-height: 5rem;
|
118 |
+
resize: vertical;
|
119 |
+
|
120 |
+
&::placeholder {
|
121 |
+
color: var(--input-placeholder-color);
|
122 |
+
}
|
123 |
+
}
|
124 |
}
|
src/components/wysiwyg/index.tsx
ADDED
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import style from "./style.module.scss";
|
2 |
+
import {Preview} from "@/components/preview";
|
3 |
+
import cn from "classnames";
|
4 |
+
import {useState, useRef, MutableRef, useEffect} from "preact/hooks";
|
5 |
+
import {JSX} from "preact";
|
6 |
+
import {
|
7 |
+
User,
|
8 |
+
Type,
|
9 |
+
Bold,
|
10 |
+
Italic,
|
11 |
+
Underline,
|
12 |
+
ChevronRight,
|
13 |
+
EyeOff,
|
14 |
+
Smile,
|
15 |
+
Image,
|
16 |
+
} from "preact-feather";
|
17 |
+
import {smileysMap} from "@/utils/smileys";
|
18 |
+
import {Button} from "@/components/button";
|
19 |
+
import {Input} from "@/components/input";
|
20 |
+
import {Alert} from "@/components/alert";
|
21 |
+
|
22 |
+
const iconSize = 14;
|
23 |
+
// const initialText = "> zefgze https://image.noelshack.com/fichiers/2018/13/4/1522325846-jesusopti.png\n" +
|
24 |
+
// "> zgfzreg :-)))\n" +
|
25 |
+
// "> > ergerg\n" +
|
26 |
+
// "zezefzef\n" +
|
27 |
+
// "<spoil>loool</spoil> AHI";
|
28 |
+
|
29 |
+
export function Wysiwyg(props: {
|
30 |
+
showTitle: boolean,
|
31 |
+
onSubmit: (user: string, title: string, text: string) => void,
|
32 |
+
disabled?: boolean,
|
33 |
+
initialText?: string,
|
34 |
+
text?: string,
|
35 |
+
setText?: (text: string) => void,
|
36 |
+
}) {
|
37 |
+
const [errors, setErrors] = useState<string[]>([]);
|
38 |
+
const [title, setTitle] = useState("");
|
39 |
+
const [user, setUser] = useState("");
|
40 |
+
const [text, setText] = props.text !== undefined && props.setText !== undefined ? [props.text, props.setText] : useState(props.initialText || "");
|
41 |
+
const [accordion, setAccordion] = useState<"smileys"|"stickers"|false>(false);
|
42 |
+
|
43 |
+
// Create a ref for the textarea
|
44 |
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
45 |
+
const selectionRange = useRef<[start: number, end: number]|null>(null);
|
46 |
+
|
47 |
+
// // Example function to demonstrate interaction with the ref
|
48 |
+
// const focusTextarea = () => {
|
49 |
+
// if (textareaRef.current) {
|
50 |
+
// textareaRef.current.focus();
|
51 |
+
// }
|
52 |
+
// };
|
53 |
+
|
54 |
+
useEffect(() => {
|
55 |
+
if (textareaRef.current && selectionRange.current) {
|
56 |
+
const [start, end] = selectionRange.current;
|
57 |
+
textareaRef.current.setSelectionRange(start, end);
|
58 |
+
selectionRange.current = null;
|
59 |
+
}
|
60 |
+
}, [text])
|
61 |
+
|
62 |
+
const bold = createAction(textareaRef, (start, end) => {
|
63 |
+
const tag = "'''";
|
64 |
+
|
65 |
+
setText(insertAt(insertAt(text, tag, start), tag, end + tag.length));
|
66 |
+
selectionRange.current = [start + tag.length, end + tag.length];
|
67 |
+
});
|
68 |
+
|
69 |
+
const italic = createAction(textareaRef, (start, end) => {
|
70 |
+
const tag = "''";
|
71 |
+
|
72 |
+
setText(insertAt(insertAt(text, tag, start), tag, end + tag.length));
|
73 |
+
selectionRange.current = [start + tag.length, end + tag.length];
|
74 |
+
});
|
75 |
+
|
76 |
+
const underline = createAction(textareaRef, (start, end) => {
|
77 |
+
const startTag = "<u>";
|
78 |
+
const endTag = "</u>";
|
79 |
+
|
80 |
+
setText(insertAt(insertAt(text, startTag, start), endTag, end + startTag.length));
|
81 |
+
selectionRange.current = [start + startTag.length, end + startTag.length];
|
82 |
+
});
|
83 |
+
|
84 |
+
const quote = createAction(textareaRef, (start, end) => {
|
85 |
+
const lines = text.split("\n");
|
86 |
+
const newLines: string[] = [];
|
87 |
+
let charCount = 0;
|
88 |
+
let quoteEnd = 0;
|
89 |
+
let addedChars = 0;
|
90 |
+
for (const line of lines) {
|
91 |
+
charCount += line.length + 1; // Add "\n"
|
92 |
+
if (charCount > start && (charCount - line.length) <= end + 1) {
|
93 |
+
newLines.push(`> ${line}`);
|
94 |
+
addedChars += 2;
|
95 |
+
quoteEnd = charCount;
|
96 |
+
} else {
|
97 |
+
newLines.push(line);
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
setText(newLines.join("\n"));
|
102 |
+
selectionRange.current = [quoteEnd + addedChars - 1, quoteEnd + addedChars - 1];
|
103 |
+
});
|
104 |
+
|
105 |
+
const spoil = createAction(textareaRef, (start, end) => {
|
106 |
+
const startTag = "<spoil>";
|
107 |
+
const endTag = "</spoil>";
|
108 |
+
|
109 |
+
setText(insertAt(insertAt(text, startTag, start), endTag, end + startTag.length));
|
110 |
+
selectionRange.current = [start + startTag.length, end + startTag.length];
|
111 |
+
});
|
112 |
+
|
113 |
+
return (
|
114 |
+
<div>
|
115 |
+
<Alert lines={errors}/>
|
116 |
+
<Input
|
117 |
+
type="text"
|
118 |
+
icon={User}
|
119 |
+
value={user}
|
120 |
+
onChange={v => setUser(v as string)}
|
121 |
+
placeholder="Pseudo"
|
122 |
+
/>
|
123 |
+
{
|
124 |
+
props.showTitle ? (
|
125 |
+
<Input
|
126 |
+
type="text"
|
127 |
+
icon={Type}
|
128 |
+
value={title}
|
129 |
+
onChange={v => setTitle(v as string)}
|
130 |
+
placeholder="Titre du sujet"
|
131 |
+
/>
|
132 |
+
) : null
|
133 |
+
}
|
134 |
+
<div className={style.container}>
|
135 |
+
<div className={style.toolbar}>
|
136 |
+
<div className={style.toolbarGroup}>
|
137 |
+
<div className={style.buttonContainer} title="Gras" onMouseDown={bold}>
|
138 |
+
<Bold size={iconSize}/>
|
139 |
+
</div>
|
140 |
+
<div className={style.buttonContainer} title="Italique" onMouseDown={italic}>
|
141 |
+
<Italic size={iconSize}/>
|
142 |
+
</div>
|
143 |
+
<div className={style.buttonContainer} title="Souligné" onMouseDown={underline}>
|
144 |
+
<Underline size={iconSize}/>
|
145 |
+
</div>
|
146 |
+
</div>
|
147 |
+
<div className={style.toolbarGroup}>
|
148 |
+
<div className={style.buttonContainer} title="Citation" onMouseDown={quote}>
|
149 |
+
<ChevronRight size={iconSize}/>
|
150 |
+
</div>
|
151 |
+
<div className={style.buttonContainer} title="Spoil" onMouseDown={spoil}>
|
152 |
+
<EyeOff size={iconSize}/>
|
153 |
+
</div>
|
154 |
+
</div>
|
155 |
+
<div className={style.toolbarGroup}>
|
156 |
+
<div
|
157 |
+
className={style.buttonContainer}
|
158 |
+
title="Smileys"
|
159 |
+
onClick={() => {
|
160 |
+
setAccordion(accordion ? false : "smileys")
|
161 |
+
}}
|
162 |
+
>
|
163 |
+
<Smile size={iconSize}/>
|
164 |
+
</div>
|
165 |
+
<div className={style.buttonContainer} title="Stickers">
|
166 |
+
<Image size={iconSize}/>
|
167 |
+
</div>
|
168 |
+
</div>
|
169 |
+
</div>
|
170 |
+
<div className={cn(style.accordion, {[style.accordionOpened]: accordion})}>
|
171 |
+
<SmileyList
|
172 |
+
text={text}
|
173 |
+
setText={setText}
|
174 |
+
textareaRef={textareaRef}
|
175 |
+
selectionRange={selectionRange}
|
176 |
+
/>
|
177 |
+
</div>
|
178 |
+
<div className={style.editor}>
|
179 |
+
<textarea
|
180 |
+
className={style.textarea}
|
181 |
+
placeholder="Saisissez un message…"
|
182 |
+
name="wysiwyg"
|
183 |
+
id="wysiwyg"
|
184 |
+
cols="30"
|
185 |
+
rows="10"
|
186 |
+
value={text}
|
187 |
+
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
188 |
+
ref={textareaRef} // Attach ref to textarea
|
189 |
+
/>
|
190 |
+
</div>
|
191 |
+
</div>
|
192 |
+
<div className={cn(style.container, style.previewContainer)}>
|
193 |
+
<Preview raw={text}/>
|
194 |
+
</div>
|
195 |
+
<Button
|
196 |
+
secondary={true}
|
197 |
+
onClick={() => {
|
198 |
+
const errors: string[] = [];
|
199 |
+
|
200 |
+
if(user.length < 2) {
|
201 |
+
errors.push("Le pseudo doit se composer au minimum de 3 caractères");
|
202 |
+
}
|
203 |
+
|
204 |
+
if(user.match(/[^a-zA-Z0-9_-]/)) {
|
205 |
+
errors.push("Le pseudo doit uniquement contenir les caractères suivant: a-z, A-Z, 0-9, _ et -");
|
206 |
+
}
|
207 |
+
|
208 |
+
if(props.showTitle && title.length < 2) {
|
209 |
+
errors.push("Le titre du sujet doit se composer au minimum de 3 caractères");
|
210 |
+
}
|
211 |
+
|
212 |
+
if(text.length < 2) {
|
213 |
+
errors.push("Le text du sujet doit se composer au minimum de 3 caractères");
|
214 |
+
}
|
215 |
+
|
216 |
+
setErrors(errors);
|
217 |
+
|
218 |
+
if(errors.length > 0) {
|
219 |
+
return;
|
220 |
+
}
|
221 |
+
|
222 |
+
props.onSubmit(user, title, text);
|
223 |
+
setUser("");
|
224 |
+
setTitle("");
|
225 |
+
setText("");
|
226 |
+
}}
|
227 |
+
disabled={props.disabled}
|
228 |
+
>
|
229 |
+
Poster
|
230 |
+
</Button>
|
231 |
+
</div>
|
232 |
+
);
|
233 |
+
}
|
234 |
+
|
235 |
+
function SmileyList(props: {
|
236 |
+
text: string,
|
237 |
+
setText: (text: string) => void,
|
238 |
+
textareaRef: MutableRef<HTMLTextAreaElement | null>,
|
239 |
+
selectionRange: MutableRef<[start: number, end: number]|null>,
|
240 |
+
}) {
|
241 |
+
// const addSmiley = createAction(props.textareaRef, (start, end) => {
|
242 |
+
// const startTag = "<u>";
|
243 |
+
// const endTag = "</u>";
|
244 |
+
//
|
245 |
+
// props.setText(insertAt(insertAt(props.text, startTag, start), endTag, end + startTag.length));
|
246 |
+
// props.selectionRange.current = [start + startTag.length, end + startTag.length];
|
247 |
+
// });
|
248 |
+
|
249 |
+
const addSmiley: (smiley: string) => JSX.MouseEventHandler<HTMLDivElement> = (smiley) => (e) => {
|
250 |
+
e.preventDefault(); // Do not lose the focus on textarea
|
251 |
+
if (props.textareaRef.current) {
|
252 |
+
props.setText(insertAt(props.text, smiley, props.textareaRef.current.selectionStart));
|
253 |
+
props.selectionRange.current = [props.textareaRef.current.selectionStart + smiley.length, props.textareaRef.current.selectionStart + smiley.length];
|
254 |
+
props.textareaRef.current.focus();
|
255 |
+
}
|
256 |
+
}
|
257 |
+
|
258 |
+
return (
|
259 |
+
<div className={style.smileyList}>
|
260 |
+
{smileysMap.map((smiley) => (
|
261 |
+
<div onMouseDown={addSmiley(` ${smiley[0]}`)}>
|
262 |
+
<img
|
263 |
+
src={`https://image.jeuxvideo.com/smileys_img/${smiley[1]}.gif`}
|
264 |
+
alt={smiley[0]}
|
265 |
+
/>
|
266 |
+
</div>
|
267 |
+
))}
|
268 |
+
</div>
|
269 |
+
)
|
270 |
+
}
|
271 |
+
|
272 |
+
function createAction(
|
273 |
+
ref: MutableRef<HTMLTextAreaElement | null>,
|
274 |
+
callback: (start: number, end: number) => void
|
275 |
+
): JSX.MouseEventHandler<HTMLDivElement> {
|
276 |
+
return (e) => {
|
277 |
+
e.preventDefault(); // Do not lose the focus on textarea
|
278 |
+
if (ref.current) {
|
279 |
+
callback(ref.current.selectionStart, ref.current.selectionEnd);
|
280 |
+
ref.current.focus();
|
281 |
+
}
|
282 |
+
}
|
283 |
+
}
|
284 |
+
|
285 |
+
function insertAt(originalString: string, substring: string, position: number): string {
|
286 |
+
return originalString.slice(0, position) + substring + originalString.slice(position);
|
287 |
+
}
|
src/components/wysiwyg/style.module.scss
ADDED
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.container {
|
2 |
+
position: relative;
|
3 |
+
background: var(--bg-color-light);
|
4 |
+
border: 0.0625rem solid var(--input-border-color);
|
5 |
+
margin-bottom: 0.9375rem;
|
6 |
+
border-radius: 0.25rem;
|
7 |
+
}
|
8 |
+
|
9 |
+
.toolbar {
|
10 |
+
padding: 0.3125rem 0.3125rem 0;
|
11 |
+
}
|
12 |
+
|
13 |
+
.toolbarGroup {
|
14 |
+
border-radius: 0.25rem;
|
15 |
+
background: var(--bg-color-light);
|
16 |
+
border: 0.0625rem solid var(--input-border-color);
|
17 |
+
margin-bottom: 0.3125rem;
|
18 |
+
margin-right: 0.3125rem;
|
19 |
+
position: relative;
|
20 |
+
display: inline-flex;
|
21 |
+
vertical-align: middle;
|
22 |
+
}
|
23 |
+
|
24 |
+
.buttonContainer {
|
25 |
+
height: 1.5rem;
|
26 |
+
width: 1.5rem;
|
27 |
+
text-align: center;
|
28 |
+
color: var(--text-muted-color);
|
29 |
+
cursor: pointer;
|
30 |
+
user-select: none;
|
31 |
+
|
32 |
+
&:hover {
|
33 |
+
color: var(--text-hover-secondary);
|
34 |
+
}
|
35 |
+
|
36 |
+
& > svg {
|
37 |
+
vertical-align: middle;
|
38 |
+
}
|
39 |
+
}
|
40 |
+
|
41 |
+
.accordion {
|
42 |
+
overflow-x: hidden;
|
43 |
+
overflow-y: auto;
|
44 |
+
border-top: 0 solid var(--input-border-color);
|
45 |
+
height: 0;
|
46 |
+
transition: height 0.2s ease-in-out, border-top-width 0.2s ease-in-out;
|
47 |
+
}
|
48 |
+
|
49 |
+
.accordionOpened {
|
50 |
+
height: 150px;
|
51 |
+
border-top-width: 0.0625rem;
|
52 |
+
}
|
53 |
+
|
54 |
+
.editor {
|
55 |
+
background: var(--input-bg-color);
|
56 |
+
overflow: hidden;
|
57 |
+
border-top: 0.0625rem solid var(--input-border-color);
|
58 |
+
border-bottom-left-radius: 0.25rem;
|
59 |
+
border-bottom-right-radius: 0.25rem;
|
60 |
+
}
|
61 |
+
|
62 |
+
.textarea {
|
63 |
+
display: block;
|
64 |
+
width: 100%;
|
65 |
+
height: 12rem;
|
66 |
+
max-width: 100%;
|
67 |
+
min-width: 100%;
|
68 |
+
border: 0 solid rgba(0, 0, 0, 0);
|
69 |
+
padding: 0.625rem;
|
70 |
+
background-color: rgba(0, 0, 0, 0);
|
71 |
+
color: var(--input-text-color);
|
72 |
+
min-height: 5rem;
|
73 |
+
resize: vertical;
|
74 |
+
|
75 |
+
&::placeholder {
|
76 |
+
color: var(--input-placeholder-color);
|
77 |
+
}
|
78 |
+
}
|
79 |
+
|
80 |
+
.previewContainer {
|
81 |
+
min-height: 9.375rem;
|
82 |
+
word-wrap: break-word;
|
83 |
+
padding: 0.625rem;
|
84 |
+
}
|
85 |
+
|
86 |
+
.smileyList {
|
87 |
+
display: flex;
|
88 |
+
flex-wrap: wrap;
|
89 |
+
justify-content: space-around;
|
90 |
+
align-items: center;
|
91 |
+
gap: 0.625rem;
|
92 |
+
padding: 0.625rem;
|
93 |
+
|
94 |
+
div {
|
95 |
+
cursor: pointer;
|
96 |
+
width: 3rem;
|
97 |
+
height: 3rem;
|
98 |
+
//padding: 0.3125rem;
|
99 |
+
display: flex;
|
100 |
+
justify-content: center;
|
101 |
+
align-items: center;
|
102 |
+
user-select: none;
|
103 |
+
}
|
104 |
+
|
105 |
+
img {
|
106 |
+
display: inline-block;
|
107 |
+
}
|
108 |
+
}
|
src/contexts/log.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createContext} from "@/utils/context"
|
2 |
+
|
3 |
+
export type LogAction = (text: string, newLine?: boolean) => void;
|
4 |
+
|
5 |
+
const itemKey = "log";
|
6 |
+
|
7 |
+
export const logCtx = createContext({
|
8 |
+
initialValue: () => {
|
9 |
+
const storedLog = localStorage.getItem(itemKey);
|
10 |
+
return storedLog || "";
|
11 |
+
},
|
12 |
+
controllers: (log: string, setLog) => ({
|
13 |
+
effect: () => {
|
14 |
+
localStorage.setItem(itemKey, log);
|
15 |
+
},
|
16 |
+
actions: {
|
17 |
+
log: ((text, newLine = true) => {
|
18 |
+
console.log(text);
|
19 |
+
const prefix = `[${new Date().toLocaleTimeString('en-US', {hour12: false})}] `;
|
20 |
+
setLog(log.length < 1 ? `${newLine ? prefix : ""}${text}` : `${log}${newLine ? `\n${prefix}` : ""}${text}`);
|
21 |
+
}) satisfies LogAction,
|
22 |
+
reset: () => {
|
23 |
+
setLog("")
|
24 |
+
},
|
25 |
+
},
|
26 |
+
})
|
27 |
+
})
|
src/contexts/route.ts
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createContext} from "@/utils/context"
|
2 |
+
import {Topic} from "@/contexts/topics";
|
3 |
+
|
4 |
+
type Route = {
|
5 |
+
name: keyof typeof routes,
|
6 |
+
args: any[],
|
7 |
+
location: string,
|
8 |
+
}
|
9 |
+
|
10 |
+
const matchers: {[key: string]: (location: string) => any[] | null} = {};
|
11 |
+
|
12 |
+
function constructRoute<A>(
|
13 |
+
name: keyof typeof routes,
|
14 |
+
generateLocation: (...args: A) => string,
|
15 |
+
matcher: (location: string) => A | null,
|
16 |
+
): (...args: A) => Route {
|
17 |
+
matchers[name] = matcher;
|
18 |
+
return (...args) => ({
|
19 |
+
name,
|
20 |
+
args,
|
21 |
+
location: generateLocation(...args),
|
22 |
+
} satisfies Route);
|
23 |
+
}
|
24 |
+
|
25 |
+
export const routes = {
|
26 |
+
home: constructRoute(
|
27 |
+
"home",
|
28 |
+
(page: number) => {
|
29 |
+
// console.log(page)
|
30 |
+
if(page < 0) {
|
31 |
+
throw new Error("Page not found");
|
32 |
+
}
|
33 |
+
return page ? `/${page + 1}` : "/"
|
34 |
+
},
|
35 |
+
(location) => {
|
36 |
+
// console.log(location)
|
37 |
+
if(location === "/") {
|
38 |
+
return [0];
|
39 |
+
}
|
40 |
+
const match = location.match(/^\/(\d+)/);
|
41 |
+
// console.log([Math.max(parseInt(match[1]) - 1, 0)]);
|
42 |
+
return match ? [Math.max(parseInt(match[1]) - 1, 0)] : null;
|
43 |
+
},
|
44 |
+
// (location) => location === "/" ? [] : null,
|
45 |
+
),
|
46 |
+
topic: constructRoute(
|
47 |
+
"topic",
|
48 |
+
(id: string, page: number) => `/topic/${id}/${page}`,
|
49 |
+
(location) => {
|
50 |
+
const match = location.match(/^\/topic\/(.+)\/(\d+)/);
|
51 |
+
return match ? [match[1], Math.max(parseInt(match[2]) - 1, 1)] : null;
|
52 |
+
},
|
53 |
+
),
|
54 |
+
settings: constructRoute(
|
55 |
+
"settings",
|
56 |
+
() => "/settings",
|
57 |
+
(location) => location === "/settings" ? [] : null,
|
58 |
+
),
|
59 |
+
} as const satisfies {
|
60 |
+
[key: string]: (...args: any[]) => Route
|
61 |
+
};
|
62 |
+
|
63 |
+
function matchBrowserLocation(): Route {
|
64 |
+
for (const [name, matcher] of Object.entries(matchers)) {
|
65 |
+
const location = window.location.pathname + window.location.search;
|
66 |
+
const args = matcher(location);
|
67 |
+
if (args !== null) {
|
68 |
+
return routes[name as keyof typeof routes](...args);
|
69 |
+
}
|
70 |
+
}
|
71 |
+
return routes.home(0);
|
72 |
+
}
|
73 |
+
|
74 |
+
const history: Route[] = [];
|
75 |
+
let historyIndex = 0;
|
76 |
+
|
77 |
+
export const routeCtx = createContext({
|
78 |
+
initialValue: matchBrowserLocation,
|
79 |
+
controllers: (route: Route, setRoute) => ({
|
80 |
+
onMount: () => {
|
81 |
+
// console.log("add listener")
|
82 |
+
// Add initial route to history
|
83 |
+
history.push(route);
|
84 |
+
historyIndex = history.length - 1;
|
85 |
+
window.addEventListener('popstate', () => {
|
86 |
+
setRoute(matchBrowserLocation());
|
87 |
+
});
|
88 |
+
},
|
89 |
+
effect: () => {
|
90 |
+
const location = route.location;
|
91 |
+
if (location !== `${window.location.pathname}${window.location.search}`) {
|
92 |
+
history.push(route);
|
93 |
+
historyIndex = history.length - 1;
|
94 |
+
window.history.pushState({}, "", location);
|
95 |
+
}
|
96 |
+
},
|
97 |
+
actions: {
|
98 |
+
goBack: () => {
|
99 |
+
// console.log(history);
|
100 |
+
// console.log(historyIndex);
|
101 |
+
if (historyIndex > 0) {
|
102 |
+
historyIndex--;
|
103 |
+
setRoute(history[historyIndex]);
|
104 |
+
} else {
|
105 |
+
setRoute(routes.home(0));
|
106 |
+
}
|
107 |
+
},
|
108 |
+
getHistoryIndex: () => historyIndex,
|
109 |
+
// goBack: (route: Route, setRoute) => {
|
110 |
+
// if (historyIndex > 0) {
|
111 |
+
// historyIndex--;
|
112 |
+
//
|
113 |
+
// }
|
114 |
+
// }
|
115 |
+
// goForward: () => {
|
116 |
+
// if (historyIndex < history.length - 1) {}
|
117 |
+
// }
|
118 |
+
}
|
119 |
+
})
|
120 |
+
})
|
src/contexts/settings.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createContext} from "@/utils/context"
|
2 |
+
|
3 |
+
export type Settings = {
|
4 |
+
apiType: "ooba"|"beam";
|
5 |
+
apiURL: string;
|
6 |
+
apiKey: string;
|
7 |
+
temperature: number;
|
8 |
+
postCount: number;
|
9 |
+
};
|
10 |
+
|
11 |
+
const defaultSettings: Settings = {
|
12 |
+
apiType: "beam",
|
13 |
+
apiURL: "",
|
14 |
+
apiKey: "",
|
15 |
+
temperature: 0.9,
|
16 |
+
postCount: 3,
|
17 |
+
}
|
18 |
+
|
19 |
+
const itemKey = "settings";
|
20 |
+
|
21 |
+
export const settingsCtx = createContext({
|
22 |
+
initialValue: () => {
|
23 |
+
const storedSettings = localStorage.getItem(itemKey);
|
24 |
+
if (storedSettings) {
|
25 |
+
return {...defaultSettings, ...JSON.parse(storedSettings)} as Settings;
|
26 |
+
}
|
27 |
+
|
28 |
+
return defaultSettings;
|
29 |
+
},
|
30 |
+
controllers: (settings: Settings, setSettings) => ({
|
31 |
+
effect: () => {
|
32 |
+
localStorage.setItem(itemKey, JSON.stringify(settings));
|
33 |
+
},
|
34 |
+
actions: {
|
35 |
+
reset: () => {
|
36 |
+
// localStorage.removeItem(itemKey);
|
37 |
+
setSettings(defaultSettings);
|
38 |
+
},
|
39 |
+
},
|
40 |
+
})
|
41 |
+
})
|
src/contexts/topics.ts
ADDED
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createContext} from "@/utils/context";
|
2 |
+
import {generateUUID} from "@/utils/uuids";
|
3 |
+
import {getCurrentTimeIso8601} from "@/utils/dates";
|
4 |
+
import {useContext} from "preact/hooks";
|
5 |
+
import {Settings} from "@/contexts/settings";
|
6 |
+
import {feedPosts, feedTopic} from "@/utils/model";
|
7 |
+
import {LogAction} from "@/contexts/log";
|
8 |
+
|
9 |
+
export type Post = {
|
10 |
+
user: string;
|
11 |
+
date: string; // date from AI if generated by AI tokens, ISO 8601, YYYY-MM-DDTHH:MM:SS
|
12 |
+
generationDate: string | null; //"null" means not generated by AI, ISO 8601, YYYY-MM-DDTHH:MM:SS
|
13 |
+
content: string;
|
14 |
+
}
|
15 |
+
|
16 |
+
export type Topic = {
|
17 |
+
id: string; // UUID
|
18 |
+
title: string;
|
19 |
+
posts: Post[];
|
20 |
+
}
|
21 |
+
|
22 |
+
export type TopicsContext = {
|
23 |
+
generation: "done" | "pending" | "error";
|
24 |
+
import: "done" | "error";
|
25 |
+
topics: Topic[];
|
26 |
+
}
|
27 |
+
|
28 |
+
const itemKey = "topics";
|
29 |
+
|
30 |
+
export const topicsCtx = createContext({
|
31 |
+
initialValue: () => {
|
32 |
+
const storedTopics = localStorage.getItem(itemKey);
|
33 |
+
return {
|
34 |
+
generation: "done",
|
35 |
+
import: "done",
|
36 |
+
topics: storedTopics ? JSON.parse(storedTopics) as Topic[] : []
|
37 |
+
} as TopicsContext;
|
38 |
+
},
|
39 |
+
controllers: (topicsContext: TopicsContext, setTopicsContext) => ({
|
40 |
+
effect: () => {
|
41 |
+
// console.log("**")
|
42 |
+
localStorage.setItem(itemKey, JSON.stringify(topicsContext.topics));
|
43 |
+
},
|
44 |
+
actions: {
|
45 |
+
reset: (): void => {
|
46 |
+
setTopicsContext({
|
47 |
+
generation: "done",
|
48 |
+
import: "done",
|
49 |
+
topics: []
|
50 |
+
});
|
51 |
+
},
|
52 |
+
addTopic: (user: string, title: string, text: string): string => {
|
53 |
+
const id = generateUUID();
|
54 |
+
setTopicsContext({
|
55 |
+
...topicsContext,
|
56 |
+
topics: [...topicsContext.topics, {
|
57 |
+
id: id,
|
58 |
+
title,
|
59 |
+
posts: [{
|
60 |
+
user: user,
|
61 |
+
date: getCurrentTimeIso8601(),
|
62 |
+
generationDate: null,
|
63 |
+
content: text,
|
64 |
+
}]
|
65 |
+
}],
|
66 |
+
});
|
67 |
+
return id;
|
68 |
+
},
|
69 |
+
deleteTopic: (topicId: string): void => {
|
70 |
+
setTopicsContext({
|
71 |
+
...topicsContext,
|
72 |
+
topics: topicsContext.topics.filter((topic) => topic.id !== topicId)
|
73 |
+
});
|
74 |
+
},
|
75 |
+
addPost: (topicId: string, user: string, text: string): void => {
|
76 |
+
const newPost: Post = {
|
77 |
+
user: user,
|
78 |
+
date: getCurrentTimeIso8601(),
|
79 |
+
generationDate: null,
|
80 |
+
content: text,
|
81 |
+
}
|
82 |
+
setTopicsContext({
|
83 |
+
...topicsContext,
|
84 |
+
topics: topicsContext.topics.map((topic) => topic.id === topicId ? {
|
85 |
+
...topic,
|
86 |
+
posts: [...topic.posts, newPost]
|
87 |
+
} : topic)
|
88 |
+
});
|
89 |
+
},
|
90 |
+
deletePost: (topicId: string, postIndex: number): void => {
|
91 |
+
setTopicsContext({
|
92 |
+
...topicsContext,
|
93 |
+
topics: topicsContext.topics.map((topic) => {
|
94 |
+
if (topic.id !== topicId) {
|
95 |
+
return topic;
|
96 |
+
}
|
97 |
+
|
98 |
+
// Delete all posts if the first is deleted
|
99 |
+
const posts: Post[] = postIndex === 0 ? [] : topic.posts.filter((_, index) => index !== postIndex);
|
100 |
+
|
101 |
+
return {
|
102 |
+
...topic,
|
103 |
+
posts
|
104 |
+
};
|
105 |
+
// Delete topic if it has not more posts
|
106 |
+
}).filter((t) => t.posts.length > 0)
|
107 |
+
});
|
108 |
+
},
|
109 |
+
generateTopic: async (settings: Settings, log: LogAction) => {
|
110 |
+
const id = generateUUID();
|
111 |
+
setTopicsContext({
|
112 |
+
...topicsContext,
|
113 |
+
generation: "pending",
|
114 |
+
});
|
115 |
+
log(`Topic: ${id} -> generation start.`)
|
116 |
+
feedTopic(settings, log, id, (topic: Topic) => {
|
117 |
+
if(topic.title.length < 1) return;
|
118 |
+
|
119 |
+
setTopicsContext((topicsContext: TopicsContext) => {
|
120 |
+
// console.log(topicsContext);
|
121 |
+
const topicIndex = topicsContext.topics.findIndex((topic) => topic.id === id);
|
122 |
+
|
123 |
+
// -1 if no topic found
|
124 |
+
if(topicIndex < 0) {
|
125 |
+
return {
|
126 |
+
...topicsContext,
|
127 |
+
generation: "pending",
|
128 |
+
topics: [...topicsContext.topics, topic]
|
129 |
+
} satisfies TopicsContext
|
130 |
+
}
|
131 |
+
|
132 |
+
return {
|
133 |
+
...topicsContext,
|
134 |
+
generation: "pending",
|
135 |
+
// Replace the old topic with the new one
|
136 |
+
topics: topicsContext.topics.map(oldTopic => oldTopic.id === id ? topic : oldTopic),
|
137 |
+
} satisfies TopicsContext
|
138 |
+
});
|
139 |
+
// console.log("feedTopic");
|
140 |
+
}).then(() => {
|
141 |
+
// console.log("then");
|
142 |
+
// TODO: check if the topic has been generated
|
143 |
+
setTopicsContext((topicsContext: TopicsContext) => ({
|
144 |
+
...topicsContext,
|
145 |
+
generation: "done",
|
146 |
+
}))
|
147 |
+
log(`Topic: ${id} -> generation done.`)
|
148 |
+
}).catch((e: Error) => {
|
149 |
+
setTopicsContext((topicsContext: TopicsContext) => ({
|
150 |
+
...topicsContext,
|
151 |
+
generation: "error",
|
152 |
+
}))
|
153 |
+
log(`Topic: ${id} -> generation error (${e.message}).`)
|
154 |
+
});
|
155 |
+
},
|
156 |
+
generatePosts: async (settings: Settings, log: LogAction, initialTopic: Topic) => {
|
157 |
+
setTopicsContext({
|
158 |
+
...topicsContext,
|
159 |
+
generation: "pending",
|
160 |
+
});
|
161 |
+
log(`Topic: ${initialTopic.id} -> generation start.`)
|
162 |
+
feedPosts(settings, log, initialTopic, (topic: Topic) => {
|
163 |
+
setTopicsContext((topicsContext: TopicsContext) => {
|
164 |
+
// console.log(topicsContext);
|
165 |
+
const topicIndex = topicsContext.topics.findIndex((topic) => topic.id === initialTopic.id);
|
166 |
+
|
167 |
+
// -1 if no topic found
|
168 |
+
if(topicIndex < 0) {
|
169 |
+
return {
|
170 |
+
...topicsContext,
|
171 |
+
generation: "pending",
|
172 |
+
topics: [...topicsContext.topics, topic]
|
173 |
+
} satisfies TopicsContext
|
174 |
+
}
|
175 |
+
|
176 |
+
return {
|
177 |
+
...topicsContext,
|
178 |
+
generation: "pending",
|
179 |
+
// Replace the old topic with the new one
|
180 |
+
topics: topicsContext.topics.map(oldTopic => oldTopic.id === initialTopic.id ? topic : oldTopic),
|
181 |
+
} satisfies TopicsContext
|
182 |
+
});
|
183 |
+
}).then(() => {
|
184 |
+
setTopicsContext((topicsContext: TopicsContext) => ({
|
185 |
+
...topicsContext,
|
186 |
+
generation: "done",
|
187 |
+
}))
|
188 |
+
log(`Topic: ${initialTopic.id} -> generation done.`)
|
189 |
+
}).catch((e: Error) => {
|
190 |
+
setTopicsContext((topicsContext: TopicsContext) => ({
|
191 |
+
...topicsContext,
|
192 |
+
generation: "error",
|
193 |
+
}))
|
194 |
+
log(`Topic: ${initialTopic.id} -> generation error (${e.message}).`)
|
195 |
+
});
|
196 |
+
},
|
197 |
+
importTopic: (json: string): void => {
|
198 |
+
const error = (message: string): void => {
|
199 |
+
throw new Error(message)
|
200 |
+
}
|
201 |
+
try {
|
202 |
+
const object = JSON.parse(json);
|
203 |
+
const id = generateUUID();
|
204 |
+
const topic: Topic = {
|
205 |
+
id: id,
|
206 |
+
title: typeof object.title == "string" ? object.title : error("title must be a string"),
|
207 |
+
posts: object.posts instanceof Array ? object.posts.map((object: unknown): Post => ({
|
208 |
+
user: typeof object.user == "string" ? object.user : error("posts.user must be a string"),
|
209 |
+
date: typeof object.date == "string" ? object.date : error("posts.date must be a string"),
|
210 |
+
generationDate: typeof object.generationDate == "string" || object.generationDate === null ? object.generationDate : error("posts.generationDate must be null or string"),
|
211 |
+
content: typeof object.content == "string" ? object.content : error("posts.content must be a string"),
|
212 |
+
})) : error("posts must be a string"),
|
213 |
+
}
|
214 |
+
setTopicsContext({
|
215 |
+
...topicsContext,
|
216 |
+
topics: [...topicsContext.topics, topic],
|
217 |
+
import: "done",
|
218 |
+
});
|
219 |
+
} catch (e) {
|
220 |
+
console.error(e);
|
221 |
+
setTopicsContext({
|
222 |
+
...topicsContext,
|
223 |
+
import: "error",
|
224 |
+
});
|
225 |
+
}
|
226 |
+
}
|
227 |
+
},
|
228 |
+
})
|
229 |
+
})
|
230 |
+
|
src/index.ts
CHANGED
@@ -1,4 +1,21 @@
|
|
1 |
-
import {
|
2 |
-
import {App} from "./app"
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {h, render} from "preact"
|
2 |
+
import {App} from "./app"
|
3 |
+
import {ContextsProvider} from "@/utils/context";
|
4 |
+
import {routeCtx} from "@/contexts/route";
|
5 |
+
import {settingsCtx} from "@/contexts/settings";
|
6 |
+
import {topicsCtx} from "@/contexts/topics";
|
7 |
+
import {logCtx} from "@/contexts/log";
|
8 |
|
9 |
+
const container = document.getElementById('app');
|
10 |
+
|
11 |
+
if (!container) throw new Error("Root element not found not found")
|
12 |
+
|
13 |
+
// @ts-ignore
|
14 |
+
render(h(ContextsProvider, {
|
15 |
+
contexts: [
|
16 |
+
routeCtx,
|
17 |
+
settingsCtx,
|
18 |
+
topicsCtx,
|
19 |
+
logCtx,
|
20 |
+
]
|
21 |
+
}, h(App, null)), container)
|
src/style.module.scss
CHANGED
@@ -9,5 +9,5 @@
|
|
9 |
}
|
10 |
|
11 |
.main {
|
12 |
-
|
13 |
}
|
|
|
9 |
}
|
10 |
|
11 |
.main {
|
12 |
+
padding-bottom: 0.9375rem;
|
13 |
}
|
src/utils/beam.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {Settings} from "@/contexts/settings";
|
2 |
+
import {LogAction} from "@/contexts/log";
|
3 |
+
|
4 |
+
export async function* streamAPI(
|
5 |
+
prompt: string,
|
6 |
+
settings: Settings,
|
7 |
+
log: LogAction,
|
8 |
+
): AsyncGenerator<string> {
|
9 |
+
log(`Fetching ${settings.postCount} posts from Beam API…`);
|
10 |
+
const response = await fetch(new URL(settings.apiURL), {
|
11 |
+
method: "POST",
|
12 |
+
headers: {
|
13 |
+
"Content-Type": "application/json",
|
14 |
+
"Accept": "text/event-stream",
|
15 |
+
"Authorization": `Bearer ${settings.apiKey}`
|
16 |
+
},
|
17 |
+
body: JSON.stringify({
|
18 |
+
"prompt": prompt,
|
19 |
+
"posts_count": settings.postCount,
|
20 |
+
"temperature": settings.temperature,
|
21 |
+
}),
|
22 |
+
});
|
23 |
+
|
24 |
+
if (!response.ok) {
|
25 |
+
const message = `Failed to fetch API (${response.status} ${response.statusText}): ${await response.text()}`;
|
26 |
+
log(message);
|
27 |
+
throw new Error(message);
|
28 |
+
}
|
29 |
+
|
30 |
+
log("Reading HTTP stream…");
|
31 |
+
try {
|
32 |
+
const decoder = new TextDecoder();
|
33 |
+
for await (const chunk of response.body) {
|
34 |
+
const text = decoder.decode(chunk, { stream: true });
|
35 |
+
yield text;
|
36 |
+
}
|
37 |
+
log("Stream reading completed.");
|
38 |
+
} catch(e) {
|
39 |
+
log("Error while reading the HTTP stream.")
|
40 |
+
}
|
41 |
+
}
|
src/utils/context.ts
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type {ComponentChildren} from "preact";
|
2 |
+
import {Context, createContext as preactCreateContext, h, JSX} from "preact";
|
3 |
+
import {Dispatch, StateUpdater, useEffect, useRef, useState} from "preact/hooks";
|
4 |
+
|
5 |
+
// TODO: use a context in another context
|
6 |
+
type Actions = {
|
7 |
+
[key: string]: (...rest: unknown[]) => unknown;
|
8 |
+
};
|
9 |
+
|
10 |
+
type Conf<T, A extends Actions = never> = {
|
11 |
+
initialValue: T | (() => T),
|
12 |
+
controllers?: (value: T, setValue: Dispatch<StateUpdater<T>>) => {
|
13 |
+
// It will only be called after the initial render
|
14 |
+
onMount?: () => void,
|
15 |
+
|
16 |
+
// It will only be called after the initial render and after re-renders with changed value
|
17 |
+
effect?: () => void,
|
18 |
+
|
19 |
+
actions?: A
|
20 |
+
},
|
21 |
+
}
|
22 |
+
|
23 |
+
type CustomContext<T, A> = Context<[
|
24 |
+
value: T,
|
25 |
+
setter: Dispatch<StateUpdater<T>>,
|
26 |
+
// actions: A extends Actions<T, ActionsMap> ? { [K in keyof A]: (...rest: ActionsMap[K][0]) => ActionsMap[K][1] } : never,
|
27 |
+
actions: A extends Actions ? A : never,
|
28 |
+
...never[]
|
29 |
+
]>;
|
30 |
+
|
31 |
+
export function createContext<T, A>(conf: Conf<T, A>): CustomContext<T, A> {
|
32 |
+
return preactCreateContext(conf) as CustomContext<T, A>;
|
33 |
+
}
|
34 |
+
|
35 |
+
export function ContextsProvider(props: {
|
36 |
+
// contexts: Array<CustomContext<any, any, any>>,
|
37 |
+
contexts: Array<CustomContext<unknown, unknown>>,
|
38 |
+
children: ComponentChildren,
|
39 |
+
}): JSX.Element | ComponentChildren {
|
40 |
+
let node: JSX.Element | ComponentChildren = props.children;
|
41 |
+
|
42 |
+
for (let i = props.contexts.length - 1; i >= 0; i--) {
|
43 |
+
const context = props.contexts[i];
|
44 |
+
|
45 |
+
if (!context || typeof context !== "object" || !("__" in context)) {
|
46 |
+
throw new Error("Invalid context provided. Ensure all contexts conform to CustomContext.");
|
47 |
+
}
|
48 |
+
|
49 |
+
const conf = context.__ as Conf<unknown, unknown>;
|
50 |
+
|
51 |
+
// WARNING: Hooks rules: Only Call Hooks at the Top Level
|
52 |
+
const state = useState(conf.initialValue);
|
53 |
+
let actions: Actions | undefined = undefined;
|
54 |
+
|
55 |
+
if (conf.controllers) {
|
56 |
+
// TODO: do not call the controller getter every time the state is changer
|
57 |
+
const controller = conf.controllers(state[0], state[1]);
|
58 |
+
|
59 |
+
if (controller.onMount) {
|
60 |
+
useEffect(() => {
|
61 |
+
controller.onMount!();
|
62 |
+
}, [])
|
63 |
+
}
|
64 |
+
|
65 |
+
if (controller.effect) {
|
66 |
+
// Used to prevent infinite loop in case of setState called in effect
|
67 |
+
const effectRef = useRef(false);
|
68 |
+
|
69 |
+
useEffect(() => {
|
70 |
+
if (effectRef.current) return;
|
71 |
+
effectRef.current = true;
|
72 |
+
controller.effect!();
|
73 |
+
effectRef.current = false;
|
74 |
+
}, [state[0]])
|
75 |
+
}
|
76 |
+
|
77 |
+
// let actions: { [key: string]: (...args: unknown[]) => any } | undefined = undefined;
|
78 |
+
|
79 |
+
if (controller.actions) {
|
80 |
+
// actions = {};
|
81 |
+
// Object.entries(controller.actions as Actions).forEach(([key, func]) => {
|
82 |
+
// (actions as object)[key] = func;
|
83 |
+
// });
|
84 |
+
actions = controller.actions as Actions;
|
85 |
+
}
|
86 |
+
}
|
87 |
+
|
88 |
+
// @ts-expect-error
|
89 |
+
node = h(context.Provider, {value: actions ? [...state, actions] : [...state]}, node)
|
90 |
+
}
|
91 |
+
|
92 |
+
return node;
|
93 |
+
}
|
src/utils/dates.ts
CHANGED
@@ -1,7 +1,5 @@
|
|
1 |
const iso8601ToFrenchRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/;
|
2 |
export function iso8601ToFrench(iso8601: string): string {
|
3 |
-
console.log("iso8601ToTokens", iso8601)
|
4 |
-
|
5 |
const matches = iso8601.match(iso8601ToFrenchRegex);
|
6 |
|
7 |
const year = matches[1];
|
@@ -16,15 +14,6 @@ export function iso8601ToFrench(iso8601: string): string {
|
|
16 |
|
17 |
const frenchToIso8601Regex = /(\d{1,2}) ([a-zA-Z\u00C0-\u024F]+) (\d{4}) à (\d{2}):(\d{2}):(\d{2})/;
|
18 |
export function frenchToIso8601(french: string): string {
|
19 |
-
console.log("tokensToIso8601", french)
|
20 |
-
// Match the components of the date and time from the input string
|
21 |
-
|
22 |
-
// const months = {
|
23 |
-
// janvier: '01', février: '02', mars: '03', avril: '04', mai: '05',
|
24 |
-
// juin: '06', juillet: '07', août: '08', septembre: '09',
|
25 |
-
// octobre: '10', novembre: '11', décembre: '12'
|
26 |
-
// };
|
27 |
-
|
28 |
const match = french.match(frenchToIso8601Regex);
|
29 |
|
30 |
if (!match) {
|
@@ -48,4 +37,17 @@ export function frenchToIso8601(french: string): string {
|
|
48 |
const months = [
|
49 |
"janvier", "février", "mars", "avril", "mai", "juin",
|
50 |
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
|
51 |
-
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
const iso8601ToFrenchRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/;
|
2 |
export function iso8601ToFrench(iso8601: string): string {
|
|
|
|
|
3 |
const matches = iso8601.match(iso8601ToFrenchRegex);
|
4 |
|
5 |
const year = matches[1];
|
|
|
14 |
|
15 |
const frenchToIso8601Regex = /(\d{1,2}) ([a-zA-Z\u00C0-\u024F]+) (\d{4}) à (\d{2}):(\d{2}):(\d{2})/;
|
16 |
export function frenchToIso8601(french: string): string {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
const match = french.match(frenchToIso8601Regex);
|
18 |
|
19 |
if (!match) {
|
|
|
37 |
const months = [
|
38 |
"janvier", "février", "mars", "avril", "mai", "juin",
|
39 |
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
|
40 |
+
];
|
41 |
+
|
42 |
+
export function getCurrentTimeIso8601(): string {
|
43 |
+
const now = new Date();
|
44 |
+
|
45 |
+
const year = now.getFullYear().toString();
|
46 |
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
47 |
+
const day = String(now.getDate()).padStart(2, '0');
|
48 |
+
const hours = String(now.getHours()).padStart(2, '0');
|
49 |
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
50 |
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
51 |
+
|
52 |
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
53 |
+
}
|
src/utils/misc.ts
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// export function throttle(callback: () => void, wait: number): () => void {
|
2 |
+
// if (typeof function_ !== 'function') {
|
3 |
+
// throw new TypeError(`Expected the first argument to be a \`function\`, got \`${typeof function_}\`.`);
|
4 |
+
// }
|
5 |
+
//
|
6 |
+
// let timeoutId;
|
7 |
+
// let lastCallTime = 0;
|
8 |
+
//
|
9 |
+
// return function throttled(...arguments_) {
|
10 |
+
// clearTimeout(timeoutId);
|
11 |
+
//
|
12 |
+
// const now = Date.now();
|
13 |
+
// const timeSinceLastCall = now - lastCallTime;
|
14 |
+
// const delayForNextCall = wait - timeSinceLastCall;
|
15 |
+
//
|
16 |
+
// if (delayForNextCall <= 0) {
|
17 |
+
// lastCallTime = now;
|
18 |
+
// function_.apply(this, arguments_);
|
19 |
+
// } else {
|
20 |
+
// timeoutId = setTimeout(() => {
|
21 |
+
// lastCallTime = Date.now();
|
22 |
+
// function_.apply(this, arguments_);
|
23 |
+
// }, delayForNextCall);
|
24 |
+
// }
|
25 |
+
// };
|
26 |
+
// }
|
src/utils/model.ts
CHANGED
@@ -1,16 +1,93 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
3 |
-
import {
|
|
|
|
|
|
|
4 |
|
5 |
-
// const titleRegex = /Sujet\s+:\s+"([^"]+)"/;
|
6 |
const titleRegex = /Sujet\s+:\s+"(.+?)"?<\|eot_id\|>/;
|
7 |
const userRegex = /<\|im_pseudo\|>([^<]+)<\|end_pseudo\|>/;
|
8 |
const dateRegex = /<\|im_date\|>([^<]+)<\|end_date\|>/;
|
9 |
const contentRegex = /<\|begin_of_post\|>([\s\S]+)(?:<\|end_of_post\|>)?$/;
|
10 |
|
11 |
-
export function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
const topic: Topic = {
|
13 |
-
id:
|
14 |
title: "",
|
15 |
posts: [],
|
16 |
};
|
@@ -22,13 +99,14 @@ export function tokensToTopic(tokens: string): Topic {
|
|
22 |
// Split token in posts
|
23 |
// The last element is always vois, so remove it
|
24 |
for(const postTokens of tokens.split("<|end_of_post|>").slice(0, -1)) {
|
25 |
-
console.log("Post tokens:")
|
26 |
-
console.log(postTokens);
|
27 |
|
28 |
// If it's the first post
|
29 |
if(topic.posts.length < 1) {
|
30 |
const titleMatch = postTokens.match(titleRegex);
|
31 |
-
|
|
|
32 |
|
33 |
topic.title = titleMatch[1];
|
34 |
}
|
@@ -40,7 +118,7 @@ export function tokensToTopic(tokens: string): Topic {
|
|
40 |
return topic;
|
41 |
}
|
42 |
|
43 |
-
|
44 |
const posts: Post[] = [];
|
45 |
|
46 |
for(const postTokens of tokens.split("<|end_of_post|>")) {
|
@@ -50,21 +128,25 @@ export function tokensToPosts(tokens: string): Post[] {
|
|
50 |
continue;
|
51 |
}
|
52 |
|
53 |
-
console.log("Post tokens:")
|
54 |
-
console.log(postTokens);
|
55 |
|
56 |
const userMatch = postTokens.match(userRegex);
|
57 |
-
|
|
|
58 |
|
59 |
const dateMatch = postTokens.match(dateRegex);
|
60 |
-
|
|
|
61 |
|
62 |
const contentMatch = postTokens.match(contentRegex);
|
63 |
-
|
|
|
64 |
|
65 |
posts.push({
|
66 |
user: userMatch[1],
|
67 |
date: frenchToIso8601(dateMatch[1]),
|
|
|
68 |
content: contentMatch[1],
|
69 |
});
|
70 |
}
|
@@ -73,7 +155,7 @@ export function tokensToPosts(tokens: string): Post[] {
|
|
73 |
}
|
74 |
|
75 |
|
76 |
-
|
77 |
if (topic.posts.length === 0) {
|
78 |
throw new Error("Topic must have at least one post")
|
79 |
}
|
@@ -92,7 +174,7 @@ export function tokenizeTopic(topic: Topic): string {
|
|
92 |
return lines.join("\n") + tokenizedPosts;
|
93 |
}
|
94 |
|
95 |
-
|
96 |
let lines = [
|
97 |
`<|eot_id|><|start_header_id|><|${post.user === poster ? "autheur" : "khey"}|>`,
|
98 |
"<|end_header_id|>",
|
|
|
1 |
+
import {throttle} from "throttle-debounce";
|
2 |
+
import {Post, Topic} from "@/contexts/topics";
|
3 |
+
import {iso8601ToFrench, frenchToIso8601, getCurrentTimeIso8601} from "./dates"
|
4 |
+
import {Settings} from "@/contexts/settings";
|
5 |
+
import {LogAction} from "@/contexts/log";
|
6 |
+
import {streamAPI as beamStreamAPI} from "@/utils/beam";
|
7 |
|
|
|
8 |
const titleRegex = /Sujet\s+:\s+"(.+?)"?<\|eot_id\|>/;
|
9 |
const userRegex = /<\|im_pseudo\|>([^<]+)<\|end_pseudo\|>/;
|
10 |
const dateRegex = /<\|im_date\|>([^<]+)<\|end_date\|>/;
|
11 |
const contentRegex = /<\|begin_of_post\|>([\s\S]+)(?:<\|end_of_post\|>)?$/;
|
12 |
|
13 |
+
export async function feedTopic(
|
14 |
+
settings: Settings,
|
15 |
+
log: LogAction,
|
16 |
+
topicId: string,
|
17 |
+
feed: (topic: Topic) => void
|
18 |
+
): Promise<void> {
|
19 |
+
// console.log(settings);
|
20 |
+
let fetcher: (prompt: string, settings: Settings, log: LogAction) => AsyncGenerator<string>;
|
21 |
+
|
22 |
+
if (settings.apiType === "beam") {
|
23 |
+
fetcher = beamStreamAPI;
|
24 |
+
}
|
25 |
+
|
26 |
+
const throttledTokensToTopic = throttle(250, (buffer: string) => {
|
27 |
+
try {
|
28 |
+
// console.log("-");
|
29 |
+
feed(tokensToTopic(topicId, buffer));
|
30 |
+
// console.log("_");
|
31 |
+
} catch (e) {
|
32 |
+
// --
|
33 |
+
}
|
34 |
+
}, {noLeading: true, noTrailing: false, debounceMode: false});
|
35 |
+
|
36 |
+
let buffer = "";
|
37 |
+
for await (const tokens of fetcher("", settings, log)) {
|
38 |
+
// console.log(".");
|
39 |
+
buffer += tokens;
|
40 |
+
throttledTokensToTopic(buffer);
|
41 |
+
}
|
42 |
+
|
43 |
+
throttledTokensToTopic.cancel();
|
44 |
+
|
45 |
+
// console.log("loool")
|
46 |
+
|
47 |
+
feed(tokensToTopic(topicId, buffer));
|
48 |
+
}
|
49 |
+
|
50 |
+
export async function feedPosts(
|
51 |
+
settings: Settings,
|
52 |
+
log: LogAction,
|
53 |
+
topic: Topic,
|
54 |
+
feed: (topic: Topic) => void // topic with posts added
|
55 |
+
): Promise<void> {
|
56 |
+
// TODO: to avoid too long context:
|
57 |
+
// If the topic exceed a certain amount of posts -> only take the 3 first post and the 3 last to generate the context
|
58 |
+
const context = tokenizeTopic(topic);
|
59 |
+
|
60 |
+
let fetcher: (prompt: string, settings: Settings, log: LogAction) => AsyncGenerator<string>;
|
61 |
+
|
62 |
+
if (settings.apiType === "beam") {
|
63 |
+
fetcher = beamStreamAPI;
|
64 |
+
}
|
65 |
+
|
66 |
+
const throttledTokensToTopic = throttle(250, (buffer: string) => {
|
67 |
+
try {
|
68 |
+
// console.log("-");
|
69 |
+
feed(tokensToTopic(topic.id, buffer));
|
70 |
+
// console.log("_");
|
71 |
+
} catch (e) {
|
72 |
+
// --
|
73 |
+
}
|
74 |
+
}, {noLeading: true, noTrailing: false, debounceMode: false});
|
75 |
+
|
76 |
+
let buffer = context;
|
77 |
+
for await (const tokens of fetcher(context, settings, log)) {
|
78 |
+
// console.log(".");
|
79 |
+
buffer += tokens;
|
80 |
+
throttledTokensToTopic(buffer);
|
81 |
+
}
|
82 |
+
|
83 |
+
throttledTokensToTopic.cancel();
|
84 |
+
|
85 |
+
feed(tokensToTopic(topic.id, buffer));
|
86 |
+
}
|
87 |
+
|
88 |
+
function tokensToTopic(id: string, tokens: string): Topic {
|
89 |
const topic: Topic = {
|
90 |
+
id: id,
|
91 |
title: "",
|
92 |
posts: [],
|
93 |
};
|
|
|
99 |
// Split token in posts
|
100 |
// The last element is always vois, so remove it
|
101 |
for(const postTokens of tokens.split("<|end_of_post|>").slice(0, -1)) {
|
102 |
+
// console.log("Post tokens:")
|
103 |
+
// console.log(postTokens);
|
104 |
|
105 |
// If it's the first post
|
106 |
if(topic.posts.length < 1) {
|
107 |
const titleMatch = postTokens.match(titleRegex);
|
108 |
+
if(!titleMatch) throw new Error("Impossible de trouver le titre du sujet");
|
109 |
+
// console.log(`title: ${titleMatch[1]}`)
|
110 |
|
111 |
topic.title = titleMatch[1];
|
112 |
}
|
|
|
118 |
return topic;
|
119 |
}
|
120 |
|
121 |
+
function tokensToPosts(tokens: string): Post[] {
|
122 |
const posts: Post[] = [];
|
123 |
|
124 |
for(const postTokens of tokens.split("<|end_of_post|>")) {
|
|
|
128 |
continue;
|
129 |
}
|
130 |
|
131 |
+
// console.log("Post tokens:")
|
132 |
+
// console.log(postTokens);
|
133 |
|
134 |
const userMatch = postTokens.match(userRegex);
|
135 |
+
if(!userMatch) throw new Error("Impossible de trouver le nom de l'auteur du message");
|
136 |
+
// console.log(`user: ${userMatch[1]}`)
|
137 |
|
138 |
const dateMatch = postTokens.match(dateRegex);
|
139 |
+
if(!dateMatch) throw new Error("Impossible de trouver la date du message");
|
140 |
+
// console.log(`date: ${dateMatch[1]}`)
|
141 |
|
142 |
const contentMatch = postTokens.match(contentRegex);
|
143 |
+
if(!contentMatch) throw new Error("Impossible de trouver le contenu du message");
|
144 |
+
// console.log(`content: ${contentMatch[1]}`)
|
145 |
|
146 |
posts.push({
|
147 |
user: userMatch[1],
|
148 |
date: frenchToIso8601(dateMatch[1]),
|
149 |
+
generationDate: getCurrentTimeIso8601(),
|
150 |
content: contentMatch[1],
|
151 |
});
|
152 |
}
|
|
|
155 |
}
|
156 |
|
157 |
|
158 |
+
function tokenizeTopic(topic: Topic): string {
|
159 |
if (topic.posts.length === 0) {
|
160 |
throw new Error("Topic must have at least one post")
|
161 |
}
|
|
|
174 |
return lines.join("\n") + tokenizedPosts;
|
175 |
}
|
176 |
|
177 |
+
function tokenizePost(post: Post, poster: string): string {
|
178 |
let lines = [
|
179 |
`<|eot_id|><|start_header_id|><|${post.user === poster ? "autheur" : "khey"}|>`,
|
180 |
"<|end_header_id|>",
|
src/utils/oobabooga.ts
CHANGED
@@ -1,12 +1,6 @@
|
|
1 |
-
import {Post, Topic} from "
|
2 |
-
import {Settings} from "
|
3 |
-
import {
|
4 |
-
import {tokenizeTopic, tokensToPosts, tokensToTopic} from "./model";
|
5 |
-
// import {replaceSmileysInText} from "./smileys";
|
6 |
-
//
|
7 |
-
// try {
|
8 |
-
// console.log(replaceSmileysInText("lol"))
|
9 |
-
// }catch(e) {}
|
10 |
|
11 |
// @see https://github.com/openai/openai-node/blob/14784f95797d4d525dafecfd4ec9c7a133540da0/src/resources/chat/completions.ts
|
12 |
type OobaboogaStreamChunk = {
|
@@ -29,19 +23,10 @@ type OobaboogaStreamChunk = {
|
|
29 |
};
|
30 |
};
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
const rawOutput = await fetApiWithStream(settings, "<|start_header_id|>", nPosts);
|
35 |
-
// const rawOutput = await fetApi(settings);
|
36 |
-
// console.log(rawOutput);
|
37 |
-
// let rawOutput = "rawOutput";
|
38 |
-
|
39 |
-
return tokensToTopic(rawOutput);
|
40 |
-
}
|
41 |
-
|
42 |
-
export async function generatePosts(settings: Settings, nPosts: number, topic: Topic): Promise<Post[]> {
|
43 |
// console.log(settings);
|
44 |
-
const rawOutput = await fetApiWithStream(settings, tokenizeTopic(topic)
|
45 |
// const rawOutput = await fetApi(settings);
|
46 |
// console.log(rawOutput);
|
47 |
// let rawOutput = "rawOutput";
|
@@ -52,44 +37,12 @@ export async function generatePosts(settings: Settings, nPosts: number, topic: T
|
|
52 |
return tokensToPosts(rawOutput);
|
53 |
}
|
54 |
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
async function fetApi(settings: Settings): Promise<string> {
|
59 |
-
const response = await fetch(new URL("/v1/completions", settings.apiURL), {
|
60 |
-
method: "POST",
|
61 |
-
headers: {
|
62 |
-
"Content-Type": "application/json",
|
63 |
-
},
|
64 |
-
body: JSON.stringify({
|
65 |
-
prompt: "<|start_header_id|>",
|
66 |
-
temperature: settings.temperature,
|
67 |
-
max_tokens: 1000,
|
68 |
-
stream: false,
|
69 |
-
skip_special_tokens: false,
|
70 |
-
stop: "<|end_of_post|>"
|
71 |
-
// top_p: 1,
|
72 |
-
// frequency_penalty: 0,
|
73 |
-
// presence_penalty: 0,
|
74 |
-
}),
|
75 |
-
});
|
76 |
-
|
77 |
-
if (response.status !== 200) {
|
78 |
-
throw new Error(`Failed to fetch API (${response.status}): ${response.statusText}`);
|
79 |
-
}
|
80 |
-
|
81 |
-
const json = await response.json();
|
82 |
-
|
83 |
-
console.log(json)
|
84 |
-
|
85 |
-
return json.choices[0].text;
|
86 |
-
}
|
87 |
-
|
88 |
const postEndToken = "<|end_of_post|>";
|
89 |
|
90 |
// @see https://github.com/openai/openai-node/issues/18
|
91 |
// nPosts: number of post before stop
|
92 |
-
async function fetApiWithStream(settings: Settings,
|
|
|
93 |
const controller = new AbortController()
|
94 |
const response = await fetch(new URL("/v1/completions", settings.apiURL), {
|
95 |
method: "POST",
|
@@ -125,7 +78,7 @@ async function fetApiWithStream(settings: Settings, prompt: string, nPosts: numb
|
|
125 |
// }
|
126 |
// });
|
127 |
|
128 |
-
console.log(`Fetching topic with ${
|
129 |
|
130 |
let endTokenCount = 0;
|
131 |
let tokens = ""; // Dont know why but the first token is skipped
|
@@ -144,7 +97,7 @@ async function fetApiWithStream(settings: Settings, prompt: string, nPosts: numb
|
|
144 |
if (text.includes(postEndToken)) {
|
145 |
endTokenCount++;
|
146 |
|
147 |
-
if(endTokenCount >=
|
148 |
finishReason = "custom_stop";
|
149 |
controller.abort();
|
150 |
break;
|
|
|
1 |
+
import {Post, Topic} from "@/contexts/topics";
|
2 |
+
import {Settings} from "@/contexts/settings";
|
3 |
+
import {LogAction} from "@/contexts/log";
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
|
5 |
// @see https://github.com/openai/openai-node/blob/14784f95797d4d525dafecfd4ec9c7a133540da0/src/resources/chat/completions.ts
|
6 |
type OobaboogaStreamChunk = {
|
|
|
23 |
};
|
24 |
};
|
25 |
|
26 |
+
// TODO
|
27 |
+
export async function generatePosts(settings: Settings, topic: Topic): Promise<Post[]> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
// console.log(settings);
|
29 |
+
const rawOutput = await fetApiWithStream(settings, tokenizeTopic(topic));
|
30 |
// const rawOutput = await fetApi(settings);
|
31 |
// console.log(rawOutput);
|
32 |
// let rawOutput = "rawOutput";
|
|
|
37 |
return tokensToPosts(rawOutput);
|
38 |
}
|
39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
const postEndToken = "<|end_of_post|>";
|
41 |
|
42 |
// @see https://github.com/openai/openai-node/issues/18
|
43 |
// nPosts: number of post before stop
|
44 |
+
async function fetApiWithStream(settings: Settings, log: LogAction, prompt: string): Promise<string> {
|
45 |
+
const postCount = settings.postCount;
|
46 |
const controller = new AbortController()
|
47 |
const response = await fetch(new URL("/v1/completions", settings.apiURL), {
|
48 |
method: "POST",
|
|
|
78 |
// }
|
79 |
// });
|
80 |
|
81 |
+
console.log(`Fetching topic with ${postCount} posts...`);
|
82 |
|
83 |
let endTokenCount = 0;
|
84 |
let tokens = ""; // Dont know why but the first token is skipped
|
|
|
97 |
if (text.includes(postEndToken)) {
|
98 |
endTokenCount++;
|
99 |
|
100 |
+
if (endTokenCount >= postCount) {
|
101 |
finishReason = "custom_stop";
|
102 |
controller.abort();
|
103 |
break;
|
src/utils/route.ts
DELETED
@@ -1,9 +0,0 @@
|
|
1 |
-
export const routes = {
|
2 |
-
home: "home",
|
3 |
-
topic: "topic",
|
4 |
-
settings: "settings",
|
5 |
-
} as const;
|
6 |
-
|
7 |
-
export type Route = typeof routes[keyof typeof routes];
|
8 |
-
|
9 |
-
export type RouteSetter = (route: Route, page?: number, id?: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/utils/settings.ts
DELETED
@@ -1,30 +0,0 @@
|
|
1 |
-
const itemKey = "settings";
|
2 |
-
|
3 |
-
export type Settings = {
|
4 |
-
apiURL: string;
|
5 |
-
temperature: number;
|
6 |
-
postCount: number;
|
7 |
-
}
|
8 |
-
|
9 |
-
const defaultSettings: Settings = {
|
10 |
-
apiURL: "http://localhost:5000",
|
11 |
-
temperature: 0.9,
|
12 |
-
postCount: 3,
|
13 |
-
}
|
14 |
-
|
15 |
-
export function fetchSettings(): Settings {
|
16 |
-
const storedSettings = localStorage.getItem(itemKey);
|
17 |
-
if (storedSettings) {
|
18 |
-
return {...defaultSettings, ...JSON.parse(storedSettings)} as Settings;
|
19 |
-
} else {
|
20 |
-
return defaultSettings;
|
21 |
-
}
|
22 |
-
}
|
23 |
-
|
24 |
-
export function saveSettings(settings: Settings) {
|
25 |
-
localStorage.setItem(itemKey, JSON.stringify(settings));
|
26 |
-
}
|
27 |
-
|
28 |
-
export function resetSettings() {
|
29 |
-
localStorage.removeItem(itemKey);
|
30 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/utils/topics.ts
DELETED
@@ -1,135 +0,0 @@
|
|
1 |
-
export type Post = {
|
2 |
-
user: string;
|
3 |
-
date: string; // ISO 8601, YYYY-MM-DDTHH:MM:SS
|
4 |
-
content: string;
|
5 |
-
}
|
6 |
-
|
7 |
-
export type Topic = {
|
8 |
-
id: string; // UUID
|
9 |
-
title: string;
|
10 |
-
posts: Post[];
|
11 |
-
}
|
12 |
-
|
13 |
-
const itemKey = "topics";
|
14 |
-
|
15 |
-
export function loadTopics(): Topic[] {
|
16 |
-
const storedTopics = localStorage.getItem(itemKey);
|
17 |
-
if (storedTopics) {
|
18 |
-
return JSON.parse(storedTopics) as Topic[];
|
19 |
-
} else {
|
20 |
-
return [];
|
21 |
-
}
|
22 |
-
}
|
23 |
-
|
24 |
-
export function saveTopics(topics: Topic[]) {
|
25 |
-
localStorage.setItem(itemKey, JSON.stringify(topics));
|
26 |
-
}
|
27 |
-
|
28 |
-
|
29 |
-
const testTopics: Topic[] = [
|
30 |
-
{
|
31 |
-
id: "dbf575b3-fc93-47b8-8773-bd91692dc97c",
|
32 |
-
title: "Les néerlandais font tous 2m",
|
33 |
-
posts: [
|
34 |
-
{
|
35 |
-
user: "elegante12",
|
36 |
-
date: "2025-01-15T09:13:44",
|
37 |
-
content: "https://vocaroo.com/1cCsqH5ai8AW\nN'allais pas ma bas si vous faites pas mini 1m80 même les meufs sont dans les 1m75 minimum"
|
38 |
-
},
|
39 |
-
{
|
40 |
-
user: "Pajoad",
|
41 |
-
date: "2025-01-15T09:14:45",
|
42 |
-
content: "Je suis (oui c'est on EST notre taille) 1m73 et j'ai été à Amsterdam en 2022, j'ai eu l'impression d'avoir 10 ans"
|
43 |
-
},
|
44 |
-
{
|
45 |
-
user: "Qui-es-tu",
|
46 |
-
date: "2025-01-15T09:15:13",
|
47 |
-
content: "Un enfer sur terre quoi."
|
48 |
-
},
|
49 |
-
]
|
50 |
-
},
|
51 |
-
{
|
52 |
-
id: "0dbfad91-c5b7-4e11-a41f-eae8162ddfa5",
|
53 |
-
title: "C'est normal qu'une fille pète au lit ?",
|
54 |
-
posts: [
|
55 |
-
{
|
56 |
-
user: "Muskis",
|
57 |
-
date: "2025-01-15T10:47:14",
|
58 |
-
content: "Quand on couche avec une fille c'est normal qu'elle pète au lit avant ou après l'amour en mode balécouilles que tu sois là ?"
|
59 |
-
},
|
60 |
-
{
|
61 |
-
user: "edreMaLeuPcvJ-4",
|
62 |
-
date: "2025-01-15T10:49:45",
|
63 |
-
content: "Non, les filles ne pètent pas.\nSi elle pète c'est qu'elle est trans :ok:"
|
64 |
-
},
|
65 |
-
{
|
66 |
-
user: "PereAttali",
|
67 |
-
date: "2025-01-15T10:49:37",
|
68 |
-
content: "Quand tu lui fous dans le fion et que ta queue est trop large c'est ce qui peut arriver (lié à la dilatation rapide)"
|
69 |
-
},
|
70 |
-
{
|
71 |
-
user: "Pajoad",
|
72 |
-
date: "2025-01-15T10:56:37",
|
73 |
-
content: "Ça a quelle odeur d'ailleurs un pet féminin ?"
|
74 |
-
},
|
75 |
-
{
|
76 |
-
user: "hypertendu",
|
77 |
-
date: "2025-01-15T10:58:15",
|
78 |
-
content: "Ça peut être un pet vaginal :("
|
79 |
-
},
|
80 |
-
]
|
81 |
-
},
|
82 |
-
{
|
83 |
-
id: "816dbd52-3f8e-4e44-9b8a-fcc388485e9f",
|
84 |
-
title: "Ta cousine veut plaquer ses FEETS sur ton visage",
|
85 |
-
posts: [
|
86 |
-
{
|
87 |
-
user: "BanDef411",
|
88 |
-
date: "2025-01-15T10:42:00",
|
89 |
-
content: "Es-tu préparé face a l’odeur? :("
|
90 |
-
},
|
91 |
-
{
|
92 |
-
user: "McLoviin",
|
93 |
-
date: "2025-01-15T10:43:35",
|
94 |
-
content: "Etant donné qu'elle me fait bander comme un taureau depuis l'adolescence je suis son tapis, son fauteuil, tout est dispo pour elle."
|
95 |
-
},
|
96 |
-
]
|
97 |
-
},
|
98 |
-
{
|
99 |
-
id: "e1504b59-3918-4ee6-bced-efcd3af3f278",
|
100 |
-
title: "[CDD] Je BAISE ta FEMME contre REMUNERATION",
|
101 |
-
posts: [
|
102 |
-
{
|
103 |
-
user: "LeFeetBossV3",
|
104 |
-
date: "2025-01-15T11:04:30",
|
105 |
-
content: "Vu qu'apparement tu sais pas la satisfaire, pour 500 balles je la baise vite fait https://image.noelshack.com/minis/2018/25/2/1529422413-risitaszoom.png\n\nElle ira mieux et votre couple sera sauvé https://image.noelshack.com/minis/2018/25/2/1529422413-risitaszoom.png"
|
106 |
-
},
|
107 |
-
{
|
108 |
-
user: "Platitude24",
|
109 |
-
date: "2025-01-15T11:05:51",
|
110 |
-
content: "Ma femme possède un pénis féminin et elle demande si elle peut te sodomiser ?"
|
111 |
-
},
|
112 |
-
]
|
113 |
-
},
|
114 |
-
{
|
115 |
-
id: "e5c288db-45a0-43e0-aa37-df485da475e7",
|
116 |
-
title: "La policière au gros CUL de Marseille est devenu MILLIONNAIRE",
|
117 |
-
posts: [
|
118 |
-
{
|
119 |
-
user: "Samanthabagare3",
|
120 |
-
date: "2025-01-15T11:59:32",
|
121 |
-
content: "https://image.noelshack.com/fichiers/2025/03/3/1736938679-screenshot-2025-01-15-11-57-10-521-com-instagram-android-edit.jpg\nhttps://image.noelshack.com/minis/2025/03/3/1736938679-screenshot-2025-01-15-11-57-10-521-com-instagram-android-edit.png\nEntrepreneuriat\" = Mym + onlyfan https://image.noelshack.com/fichiers/2018/25/2/1529422413-risitaszoom.png\n\nFrom 500 abonnés to 232 000 grâce au buzz https://image.noelshack.com/fichiers/2018/25/2/1529422413-risitaszoom.png"
|
122 |
-
},
|
123 |
-
{
|
124 |
-
user: "Platitude24",
|
125 |
-
date: "2025-01-15T11:59:36",
|
126 |
-
content: "Comment ça peut être policière ça ? :("
|
127 |
-
},
|
128 |
-
{
|
129 |
-
user: "Samanthabagare3",
|
130 |
-
date: "2025-01-15T12:00:02",
|
131 |
-
content: "> Le 15 janvier 2025 à 11:59:36 :\n> Comment ça peut être policière ça ?\n\nAHI <spoil>loool</spoil> AHI\nShitholisation du pays https://image.noelshack.com/fichiers/2018/25/2/1529422413-risitaszoom.png"
|
132 |
-
},
|
133 |
-
]
|
134 |
-
},
|
135 |
-
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tsconfig.json
CHANGED
@@ -6,14 +6,13 @@
|
|
6 |
"noEmit": true,
|
7 |
"allowJs": true,
|
8 |
"checkJs": true,
|
9 |
-
|
10 |
/* Preact Config */
|
11 |
"jsx": "react-jsx",
|
12 |
"jsxImportSource": "preact",
|
13 |
"skipLibCheck": true,
|
14 |
"paths": {
|
15 |
-
"
|
16 |
-
"react-dom": ["./node_modules/preact/compat/"]
|
17 |
},
|
18 |
"allowSyntheticDefaultImports": true
|
19 |
},
|
|
|
6 |
"noEmit": true,
|
7 |
"allowJs": true,
|
8 |
"checkJs": true,
|
9 |
+
"strict": true,
|
10 |
/* Preact Config */
|
11 |
"jsx": "react-jsx",
|
12 |
"jsxImportSource": "preact",
|
13 |
"skipLibCheck": true,
|
14 |
"paths": {
|
15 |
+
"@/*": ["./src/*"],
|
|
|
16 |
},
|
17 |
"allowSyntheticDefaultImports": true
|
18 |
},
|
vite.config.ts
CHANGED
@@ -13,6 +13,11 @@ export default defineConfig({
|
|
13 |
prefreshEnabled: false
|
14 |
})
|
15 |
],
|
|
|
|
|
|
|
|
|
|
|
16 |
build: {
|
17 |
target: "es2022",
|
18 |
minify: false,
|
|
|
13 |
prefreshEnabled: false
|
14 |
})
|
15 |
],
|
16 |
+
resolve: {
|
17 |
+
alias: {
|
18 |
+
"@": "/src",
|
19 |
+
},
|
20 |
+
},
|
21 |
build: {
|
22 |
target: "es2022",
|
23 |
minify: false,
|