Greums commited on
Commit
a417977
·
1 Parent(s): 60255ee

major improvements to the app

Browse files
Files changed (43) hide show
  1. README.md +2 -1
  2. package-lock.json +18 -1
  3. package.json +3 -1
  4. src/app.tsx +70 -167
  5. src/components/alert/index.tsx +13 -0
  6. src/components/alert/style.module.scss +18 -0
  7. src/components/button/index.tsx +2 -2
  8. src/components/input/index.tsx +2 -23
  9. src/components/input/style.module.scss +19 -3
  10. src/components/layout/index.tsx +7 -5
  11. src/components/layout/style.module.scss +15 -2
  12. src/components/pagination/index.tsx +46 -0
  13. src/components/pagination/style.module.scss +73 -0
  14. src/components/preview/index.tsx +64 -53
  15. src/components/preview/style.module.scss +5 -0
  16. src/components/radio/index.tsx +43 -0
  17. src/components/radio/style.module.scss +53 -0
  18. src/components/settings/index.tsx +59 -28
  19. src/components/settings/style.module.scss +15 -0
  20. src/components/spinner/style.module.scss +14 -10
  21. src/components/topic/index.tsx +91 -17
  22. src/components/topic/style.module.scss +47 -3
  23. src/components/topics/index.tsx +155 -43
  24. src/components/topics/style.module.scss +50 -3
  25. src/components/wysiwyg/index.tsx +287 -0
  26. src/components/wysiwyg/style.module.scss +108 -0
  27. src/contexts/log.ts +27 -0
  28. src/contexts/route.ts +120 -0
  29. src/contexts/settings.ts +41 -0
  30. src/contexts/topics.ts +230 -0
  31. src/index.ts +20 -3
  32. src/style.module.scss +1 -1
  33. src/utils/beam.ts +41 -0
  34. src/utils/context.ts +93 -0
  35. src/utils/dates.ts +14 -12
  36. src/utils/misc.ts +26 -0
  37. src/utils/model.ts +99 -17
  38. src/utils/oobabooga.ts +10 -57
  39. src/utils/route.ts +0 -9
  40. src/utils/settings.ts +0 -30
  41. src/utils/topics.ts +0 -135
  42. tsconfig.json +2 -3
  43. vite.config.ts +5 -0
README.md CHANGED
@@ -3,7 +3,8 @@ title: JVCGPT
3
  emoji: 🤣
4
  colorFrom: blue
5
  colorTo: blue
6
- sdk: docker
 
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 {fetchSettings, resetSettings, saveSettings} from "./utils/settings";
14
- import {generateTopic, generatePosts} from "./utils/oobabooga";
15
- import {tokenizeTopic} from "./utils/model";
16
 
17
  export function App(): JSX.Element {
18
- const [route, _setRoute] = useState<Route>(routes.home);
19
- const [page, setPage] = useState<number>(0);
20
- const [topicId, setTopicId] = useState<Topic["id"] | null>(null);
21
-
22
- // null when loading
23
- const [topics, setTopics] = useState<Topic[]>(loadTopics);
24
- const [latestGeneratedTopicId, setLatestGeneratedTopicId] = useState<string|null>(null);
25
- const [pendingGeneration, setPendingGeneration] = useState<boolean>(false);
26
-
27
- useEffect(() => {
28
- console.log("save !")
29
- saveTopics(topics);
30
- }, [topics]);
31
-
32
- const _generateTopic = async (postsCount: number) => {
33
- setPendingGeneration(true);
34
- const topic = await generateTopic(settings, postsCount);
35
- setLatestGeneratedTopicId(topic.id);
36
- setTopics(topics => [...topics, topic]);
37
- setPendingGeneration(false);
38
- }
39
-
40
- const addPosts = async (topicId: string, postsCount: number) => {
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 routes.topic:
150
- if (topicId === null) {
151
- routeComponent = <div>Impossible d'afficher le sujet</div>
152
- breadcrumbs = "accueil"
153
- title = "Sujet"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  } else {
155
- if (topics === null) {
156
- routeComponent = <div>Chargement...</div>
157
- breadcrumbs = `accueil / sujet`
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 routes.settings:
175
- routeComponent = <Settings settings={settings} setSettings={setSettings} resetApp={resetApp}/>
176
- breadcrumbs = "accueil / paramètres"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="#" onClick={e => {
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 = undefined;
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, "generic-input", className, {
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.75rem;
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 {Route, routes} from "../../utils/route";
 
5
 
6
  export function Layout(props: {
7
- breadcrumbs: string,
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
- props.setRoute(routes.settings);
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: 700;
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 "../../utils/smileys";
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
- // const withBr = escaped.replace(/\n/g, '<br/>')
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
- // [/(https?:\/\/image\.noelshack\.com\/\S+)/g]: "<img width=\"68\" height=\"51\" alt=\"noelshak\" src=\"$1\"/>"
54
- [ // Stickers
55
- /(^| )https?:\/\/image\.noelshack\.com\/(?:fichiers|minis)(\S+)/gm,
56
- "$1<img width=\"68\" height=\"51\" alt=\"noelshak\" src=\"https://image.noelshack.com/minis/$2\"/>"
57
  ],
58
- [ // Vocaroo
59
- /(^| )https:\/\/vocaroo.com\/(.+)/gm,
60
- "$1<div><iframe width=\"300\" height=\"60\" src=\"https://vocaroo.com/embed/$2?autoplay=0\" frameborder=\"0\" allow=\"autoplay\"></iframe></div>"
 
61
  ],
62
- [ // Citations
63
- /^(?:&gt;.*(?:\n&gt;.*)*)/g,
64
  (reg, raw) => {
65
- // return raw
66
- const match = reg.exec(raw);
67
- if (!match) return raw;
68
-
69
- console.log(match);
70
- const index = match.index;
71
- const length = match[0].length;
72
-
73
- return raw.substring(0, index) + `<blockquote>${match[0].replace(/^&gt;/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
  /&lt;spoil&gt;(.*?)&lt;\/spoil&gt;/gm,
81
- (reg, raw) => { // Citations
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
- /(^| )(https?:\/\/\S+)/gm,
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("(?:(^| )" + mapping[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ")", "gm"),
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
- let previousInput;
121
- // console.log(Object.entries(jvcodeMap))
122
- do {
123
- previousInput = input; // Keep track of input before replacements
124
- for (const [regex, htmlOrFunc] of jvcodeMap) {
125
- // const regex = new RegExp(bbCode, 'gi');
126
- // console.log(regex, html, input)
127
- if (htmlOrFunc instanceof Function) {
128
- input = htmlOrFunc(regex, input);
129
- } else {
130
- input = input.replace(regex, htmlOrFunc as string);
131
- }
 
 
132
  }
133
- // } while (input !== previousInput); // Repeat until no more replacements
134
- } while (false); // Repeat until no more replacements
135
 
136
  // console.log(input)
137
- return input;
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(/^&gt; ?/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
+ /^&gt;.*(?:\n&gt;.*)*/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
  /&lt;spoil&gt;(.*?)&lt;\/spoil&gt;/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 {Link, Thermometer, Sliders} from "preact-feather";
3
  import {Button} from "../button";
4
- import {useEffect, useState} from "preact/hooks";
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(props: {
10
- settings: SettingsType,
11
- setSettings: (settings: SettingsType) => void,
12
- resetApp: () => void,
13
- }) {
14
- // const [settings, setSettings] = useState(fetchSettings)
15
- //
16
- // useEffect(() => {
17
- // saveSettings(settings);
18
- // }, [settings]);
 
19
 
20
  return <div>
21
  <form>
22
  <FormGroup>
23
- <label htmlFor="api">API</label>
 
 
 
 
 
 
 
 
 
24
  <Input
25
  type="text"
26
- placeholder="URl d'API ex: https://ouruq7zepnehg2-5000.proxy.runpod.net/"
27
  icon={Link}
28
- value={props.settings.apiURL}
29
- onChange={(v) => props.setSettings({...props.settings, apiURL: v as string})}
 
 
 
 
 
 
 
 
 
 
30
  />
31
  </FormGroup>
32
  <FormGroup>
33
  <label for="temperature">Temperature</label>
34
  <Slider
35
  name="temperature"
36
- value={props.settings.temperature}
37
- onChange={(v) => props.setSettings({...props.settings, temperature: 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
- Réinitialiser
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: 90px;
5
- height: 120px;
6
 
7
  > div {
8
- width: 17px;
9
- height: 80px;
10
  margin: auto auto;
11
- border-radius: 4px;
12
  }
13
  }
14
 
@@ -26,21 +30,21 @@
26
 
27
  @keyframes square-anim {
28
  0% {
29
- height: 80px;
30
  background-color: var(--text-secondary);
31
  }
32
  20% {
33
- height: 80px;
34
  }
35
  40% {
36
- height: 120px;
37
  background-color: var(--text-hover-secondary);
38
  }
39
  80% {
40
- height: 80px;
41
  }
42
  100% {
43
- height: 80px;
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 "../../utils/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 {Settings as SettingsType, Settings} from "../../utils/settings";
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  export function Topic(props: {
11
  topic: TopicType,
12
- settings: Settings,
13
- setSettings: (settings: SettingsType) => void,
14
- addPosts: (topicId: string, postsCount: number) => Promise<void>,
15
- pendingGeneration: boolean,
16
  }) {
17
- console.log(props.topic)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  return (
20
  <div>
21
- {props.topic.posts.map(post => <Post post={post}/>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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={props.settings.postCount}
30
- // onChange={(v) => props.setSettings({...props.settings, temperature: v as number})}
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={() => props.addPosts(props.topic.id, props.settings.postCount)}
41
  secondary={true}
42
- loading={props.pendingGeneration}
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 "../../utils/topics";
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 {Link, Thermometer, Sliders} from "preact-feather";
9
- import {Settings as SettingsType, Settings} from "../../utils/settings";
10
- import {useMemo, useState} from "preact/hooks";
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
- topics: Topic[] | null,
18
- setRoute: RouteSetter,
19
- settings: Settings,
20
- setSettings: (settings: SettingsType) => void,
21
- generateTopic: (postCount: number) => Promise<void>,
22
- pendingGeneration: boolean,
23
- latestGeneratedTopicId: string | null,
24
  }) {
25
- // const [generationPostCount, setGenerationPostCount] = useState<number>(1)
 
 
 
26
 
27
- const sortedTopics = useMemo(() => {
28
- if (props.topics === null || props.topics.length < 1) {
29
- return props.topics;
30
  }
 
31
 
32
- return props.topics.sort((topicA, topicB) => {
 
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
- }, [props.topics]);
 
 
 
 
 
 
 
39
 
40
  return (
41
  <div>
42
- {
43
- sortedTopics === null ?
44
- <Spinner className={style.spinner}/> :
45
- <List topics={sortedTopics} setRoute={props.setRoute}
46
- latestGeneratedTopicId={props.latestGeneratedTopicId}/>
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={props.settings.postCount}
56
- onChange={(v) => props.setSettings({...props.settings, postCount: 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={() => props.generateTopic(props.settings.postCount)}
 
 
65
  secondary={true}
66
- loading={props.pendingGeneration}
67
  >
68
- Générer
69
  </Button>
70
  </div>
71
  <hr/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </div>
73
  )
74
  }
75
 
76
  function List(props: {
77
  topics: Topic[],
78
- setRoute: RouteSetter,
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></span><span></span><span></span></li>}
90
- {props.topics.map(topic => (
91
- <li className={cn({[style.highlight]: topic.id === props.latestGeneratedTopicId})}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  <span>
93
- <a href="#" onClick={(e) => {
94
  e.preventDefault();
95
- props.setRoute(routes.topic, 0, topic.id);
96
  }}>
97
  {topic.title}
98
  </a>
99
  </span>
100
- <span>{topic.posts[0].user}</span>
101
- <span>{topic.posts.length}</span>
102
- <span>{iso8601ToFrench(topic.posts[topic.posts.length - 1].date)}</span>
103
- </li>
104
- ))}
105
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {render, h} from "preact";
2
- import {App} from "./app";
 
 
 
 
 
3
 
4
- render(h(App, null), document.getElementById('app'))
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {Post, Topic} from "./topics";
2
- import {iso8601ToFrench, frenchToIso8601} from "./dates"
3
- import {generateUUID} from "./uuids";
 
 
 
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 tokensToTopic(tokens: string): Topic {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const topic: Topic = {
13
- id: generateUUID(),
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
- console.log(`title: ${titleMatch[1]}`)
 
32
 
33
  topic.title = titleMatch[1];
34
  }
@@ -40,7 +118,7 @@ export function tokensToTopic(tokens: string): Topic {
40
  return topic;
41
  }
42
 
43
- export function tokensToPosts(tokens: string): Post[] {
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
- console.log(`user: ${userMatch[1]}`)
 
58
 
59
  const dateMatch = postTokens.match(dateRegex);
60
- console.log(`date: ${dateMatch[1]}`)
 
61
 
62
  const contentMatch = postTokens.match(contentRegex);
63
- console.log(`content: ${contentMatch[1]}`)
 
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
- export function tokenizeTopic(topic: Topic): string {
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
- export function tokenizePost(post: Post, poster: string): string {
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 "./topics";
2
- import {Settings} from "./settings";
3
- import {generateUUID} from "./uuids";
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
- export async function generateTopic(settings: Settings, nPosts: number): Promise<Topic> {
33
- console.log(settings);
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), nPosts);
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, prompt: string, nPosts: number): Promise<string> {
 
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 ${nPosts} posts...`);
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 >= nPosts) {
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
- "react": ["./node_modules/preact/compat/"],
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,