Greums commited on
Commit
1982de5
·
1 Parent(s): 650cdc7

first alpha version

Browse files
Files changed (45) hide show
  1. .gitignore +24 -0
  2. README.md +9 -11
  3. index.html +13 -18
  4. package-lock.json +0 -0
  5. package.json +25 -0
  6. public/favicon.svg +40 -0
  7. src/app.tsx +202 -0
  8. src/components/button/index.tsx +37 -0
  9. src/components/button/style.module.scss +105 -0
  10. src/components/container/index.tsx +10 -0
  11. src/components/container/style.module.scss +6 -0
  12. src/components/form/index.tsx +12 -0
  13. src/components/form/style.module.scss +10 -0
  14. src/components/input/index.tsx +86 -0
  15. src/components/input/style.module.scss +45 -0
  16. src/components/layout/index.tsx +29 -0
  17. src/components/layout/style.module.scss +25 -0
  18. src/components/preview/index.tsx +161 -0
  19. src/components/preview/style.module.scss +103 -0
  20. src/components/settings/index.tsx +51 -0
  21. src/components/settings/style.module.scss +0 -0
  22. src/components/slider/index.tsx +60 -0
  23. src/components/slider/style.module.scss +330 -0
  24. src/components/spinner/index.tsx +12 -0
  25. src/components/spinner/style.module.scss +46 -0
  26. src/components/topic/index.tsx +65 -0
  27. src/components/topic/style.module.scss +51 -0
  28. src/components/topics/index.tsx +107 -0
  29. src/components/topics/style.module.scss +71 -0
  30. src/index.ts +4 -0
  31. src/reset.scss +115 -0
  32. src/style.module.scss +13 -0
  33. src/styles.scss +139 -0
  34. src/types.d.ts +0 -0
  35. src/utils/dates.ts +51 -0
  36. src/utils/model.ts +107 -0
  37. src/utils/oobabooga.ts +176 -0
  38. src/utils/route.ts +9 -0
  39. src/utils/settings.ts +26 -0
  40. src/utils/smileys.ts +79 -0
  41. src/utils/topics.ts +135 -0
  42. src/utils/uuids.ts +5 -0
  43. style.css +0 -28
  44. tsconfig.json +21 -0
  45. vite.config.ts +18 -0
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
README.md CHANGED
@@ -1,11 +1,9 @@
1
- ---
2
- title: Jvcgpt Simple Webpage
3
- emoji: 😻
4
- colorFrom: pink
5
- colorTo: pink
6
- sdk: static
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # JVCGPT
2
+
3
+ ## Getting Started
4
+
5
+ - `npm run dev` - Starts a dev server at http://localhost:5173/
6
+
7
+ - `npm run build` - Builds for production, emitting to `dist/`
8
+
9
+ - `npm run preview` - Starts a server at http://localhost:4173/ to test production build locally
 
 
index.html CHANGED
@@ -1,19 +1,14 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
7
+ <meta name="color-scheme" content="dark"/>
8
+ <title>JVCGPT</title>
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <script type="module" src="/src/index.ts"></script>
13
+ </body>
 
 
 
 
 
14
  </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "scripts": {
5
+ "dev": "vite",
6
+ "build": "vite build",
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",
16
+ "eslint": "^8.57.1",
17
+ "eslint-config-preact": "^1.5.0",
18
+ "sass-embedded": "^1.83.4",
19
+ "typescript": "^5.7.3",
20
+ "vite": "^6.0.4"
21
+ },
22
+ "eslintConfig": {
23
+ "extends": "preact"
24
+ }
25
+ }
public/favicon.svg ADDED
src/app.tsx ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, 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 updateRoute = useCallback(() => {
61
+ const url = new URL(window.location.href);
62
+ const route = url.searchParams.get("route");
63
+ if (route && route in routes) {
64
+ _setRoute(route as keyof typeof routes);
65
+ }
66
+ const page = url.searchParams.get("page");
67
+ if (page) {
68
+ setPage(parseInt(page));
69
+ }
70
+ const id = url.searchParams.get("id");
71
+ if (id) {
72
+ setTopicId(id);
73
+ }
74
+ }, []);
75
+
76
+ // Init page from url
77
+ useEffect(() => {
78
+ updateRoute()
79
+ }, []);
80
+
81
+ // Listen for URl change
82
+ useEffect(() => {
83
+ function listener() {
84
+ updateRoute()
85
+ }
86
+
87
+ window.addEventListener('popstate', listener);
88
+
89
+ return () => {
90
+ window.removeEventListener('popstate', listener)
91
+ }
92
+ }, []);
93
+
94
+
95
+ // Update URL params on route change
96
+ const setRoute: RouteSetter = useCallback((route: Route, page?: number, id?: string) => {
97
+ const url = new URL(window.location.href);
98
+ url.searchParams.set("route", route);
99
+ _setRoute(route);
100
+
101
+ if (page !== undefined) {
102
+ url.searchParams.set("page", String(page));
103
+ setPage(page);
104
+ } else {
105
+ url.searchParams.delete("page");
106
+ setPage(0);
107
+ }
108
+
109
+ if (id !== undefined) {
110
+ url.searchParams.set("id", id);
111
+ setTopicId(id);
112
+ } else {
113
+ url.searchParams.delete("id");
114
+ setTopicId(null);
115
+ }
116
+
117
+ const newUrl = url.toString();
118
+
119
+ // Avoid to push the same URL mutiple time
120
+ if (newUrl !== window.location.href) {
121
+ window.history.pushState({}, "", newUrl);
122
+ }
123
+ }, []);
124
+
125
+ let routeComponent: JSX.Element = undefined;
126
+ let breadcrumbs: string = undefined;
127
+ let title: string = undefined;
128
+
129
+ switch (route) {
130
+ case routes.home:
131
+ routeComponent = <Topics
132
+ topics={topics}
133
+ setRoute={setRoute}
134
+ settings={settings}
135
+ setSettings={setSettings}
136
+ generateTopic={_generateTopic}
137
+ pendingGeneration={pendingGeneration}
138
+ latestGeneratedTopicId={latestGeneratedTopicId}
139
+ />
140
+ breadcrumbs = "accueil"
141
+ title = "Liste des topics"
142
+ break;
143
+ case routes.topic:
144
+ if (topicId === null) {
145
+ routeComponent = <div>Impossible d'afficher le topic</div>
146
+ breadcrumbs = "accueil"
147
+ title = "Topic"
148
+ } else {
149
+ if (topics === null) {
150
+ routeComponent = <div>Chargement...</div>
151
+ breadcrumbs = `accueil / topic`
152
+ title = `Chargement...`
153
+ } else {
154
+ const topic = topics.find(t => t.id === topicId);
155
+ routeComponent = <TopicComponent
156
+ topic={topic}
157
+ settings={settings}
158
+ setSettings={setSettings}
159
+ addPosts={addPosts}
160
+ pendingGeneration={pendingGeneration}
161
+ />
162
+ breadcrumbs = `accueil / ${topic.title}`
163
+ title = `Topic : ${topic.title}`
164
+ }
165
+ }
166
+
167
+ break;
168
+ case routes.settings:
169
+ routeComponent = <Settings settings={settings} setSettings={setSettings}/>
170
+ breadcrumbs = "accueil / paramètres"
171
+ title = "Paramètres"
172
+ break;
173
+ }
174
+
175
+ return (
176
+ <>
177
+ <header className={style.header}>
178
+ <Container>
179
+ <h1 className={style.logo}>
180
+ <a href="#" onClick={e => {
181
+ e.preventDefault();
182
+ setRoute(routes.home);
183
+ }}>
184
+ JVCGPT
185
+ </a>
186
+ </h1>
187
+ </Container>
188
+ </header>
189
+ <main>
190
+ <Container>
191
+ <Layout
192
+ breadcrumbs={breadcrumbs}
193
+ title={title}
194
+ setRoute={setRoute}
195
+ >
196
+ {routeComponent}
197
+ </Layout>
198
+ </Container>
199
+ </main>
200
+ </>
201
+ );
202
+ }
src/components/button/index.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {ComponentChildren, JSX} from 'preact';
2
+ import cn from 'classnames';
3
+ import style from "./style.module.scss";
4
+
5
+ export function Button(props: {
6
+ loading?: boolean;
7
+ onClick?: () => void;
8
+ className?: string;
9
+ secondary?: boolean;
10
+ disabled?: boolean;
11
+ children: ComponentChildren;
12
+ }) {
13
+ const disabled = props.disabled || props.loading;
14
+ const buttonClass = cn(style.btn, {[style.secondary]: props.secondary}, 'button', props.className, {[style.disabled]:disabled});
15
+
16
+ let spinner: JSX.Element = undefined;
17
+
18
+ if (props.loading) {
19
+ spinner = <span className={style.spinner}/>
20
+ }
21
+
22
+ return (
23
+ <button
24
+ type="button"
25
+ onClick={() => {
26
+ if (!disabled) {
27
+ props.onClick()
28
+ }
29
+ }}
30
+ className={buttonClass}
31
+ disabled={disabled}
32
+ >
33
+ {spinner}
34
+ {props.children}
35
+ </button>
36
+ );
37
+ }
src/components/button/style.module.scss ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .btn {
2
+ position: relative;
3
+ display: inline-flex;
4
+ justify-content: center;
5
+ align-items: center;
6
+ padding: 0 1.25rem;
7
+ margin: 0;
8
+ border: 0.0625rem solid var(--text-secondary);
9
+ height: 2.25rem;
10
+ line-height: 2.25rem;
11
+ font-size: 0.9375rem;
12
+ text-decoration: none;
13
+ color: var(--text-secondary);
14
+ border-radius: 1.125rem;
15
+
16
+ white-space: nowrap;
17
+ cursor: pointer;
18
+ //overflow: hidden;
19
+
20
+ &:not(.disabled):hover {
21
+ border-color: var(--text-hover-secondary);
22
+ color: var(--text-hover-secondary);
23
+ }
24
+ //
25
+ //&:active {
26
+ // //border-color: var(--text-secondary);
27
+ // background: var(--text-secondary);
28
+ //}
29
+
30
+ &.secondary {
31
+ color: #fff;
32
+ background-color: var(--text-secondary);
33
+
34
+ &:not(.disabled):hover {
35
+ border-color: var(--text-hover-secondary);
36
+ background: var(--text-hover-secondary);
37
+ color: #fff;
38
+ }
39
+ }
40
+ }
41
+
42
+ .spinner {
43
+ font-size: 10px;
44
+ //margin: 50px auto;
45
+ margin-right: 1em;
46
+ width: 1.5em;
47
+ height: 1.5em;
48
+ border-radius: 50%;
49
+ background: #ffffff;
50
+ background: linear-gradient(to right, #ffffff 10%, rgba(255, 255, 255, 0) 42%);
51
+ position: relative;
52
+ animation: load3 1.4s infinite linear;
53
+ transform: translateZ(0);
54
+
55
+ &:before {
56
+ width: 50%;
57
+ height: 50%;
58
+ background: #ffffff;
59
+ border-radius: 100% 0 0 0;
60
+ position: absolute;
61
+ top: 0;
62
+ left: 0;
63
+ content: '';
64
+ }
65
+
66
+ &:after {
67
+ background: var(--text-secondary);
68
+ width: 75%;
69
+ height: 75%;
70
+ border-radius: 50%;
71
+ content: '';
72
+ margin: auto;
73
+ position: absolute;
74
+ top: 0;
75
+ left: 0;
76
+ bottom: 0;
77
+ right: 0;
78
+ }
79
+ }
80
+
81
+ @keyframes load3 {
82
+ 0% {
83
+ transform: rotate(0deg);
84
+ }
85
+ 100% {
86
+ transform: rotate(360deg);
87
+ }
88
+ }
89
+
90
+ .disabled {
91
+ cursor: not-allowed;
92
+
93
+ &:after {
94
+ content: "";
95
+ position: absolute;
96
+ width: 100%;
97
+ height: 100%;
98
+ background: #4f4f4f;
99
+ border: 0.0625rem solid #4f4f4f;
100
+ border-radius: 1.125rem;
101
+ opacity: 0.5;
102
+ box-sizing: content-box;
103
+ }
104
+ }
105
+
src/components/container/index.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {ComponentChildren} from "preact";
2
+ import style from "./style.module.scss"
3
+
4
+ export function Container(props: { children: ComponentChildren }) {
5
+ return (
6
+ <div class={style.container}>
7
+ {props.children}
8
+ </div>
9
+ );
10
+ }
src/components/container/style.module.scss ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .container {
2
+ margin: 0 auto;
3
+ min-width: 20rem;
4
+ max-width: 73.5rem;
5
+ padding: 0 0.5rem;
6
+ }
src/components/form/index.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {ComponentChildren} from "preact";
2
+ import style from "./style.module.scss"
3
+
4
+ export function FormGroup(props: {
5
+ children: ComponentChildren,
6
+ }) {
7
+ return (
8
+ <div className={style.formGroup}>
9
+ {props.children}
10
+ </div>
11
+ )
12
+ }
src/components/form/style.module.scss ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .formGroup {
2
+ display: flex;
3
+ flex-direction: column;
4
+ margin-bottom: 1rem;
5
+
6
+ label {
7
+ font-size: 1rem;
8
+ margin-bottom: 1rem;
9
+ }
10
+ }
src/components/input/index.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {FunctionalComponent} from "preact";
2
+ import {useState} from "preact/hooks";
3
+ import cn from "classnames";
4
+ import {JSX} from "preact";
5
+ // import GenericEventHandler = JSXInternal.GenericEventHandler;
6
+ import style from "./style.module.scss";
7
+ import {Settings} from "preact-feather";
8
+ import {FeatherProps} from "preact-feather/src/types";
9
+
10
+ interface InputProps<T> {
11
+ type: "text" | "number" | "email" | "password";
12
+ icon: FunctionalComponent<FeatherProps>;
13
+ value: T;
14
+ placeholder?: string;
15
+ onChange: (value: T) => void;
16
+ className?: string;
17
+ disabled?: boolean;
18
+ id?: string;
19
+ name?: string;
20
+ }
21
+
22
+ export const Input: FunctionalComponent<InputProps<string | number>> = ({
23
+ type,
24
+ icon,
25
+ value,
26
+ placeholder,
27
+ onChange,
28
+ className,
29
+ disabled,
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")
42
+ const target = event.target as HTMLInputElement;
43
+ onChange(type === "number" ? (parseFloat(target.value) || 0) : target.value);
44
+ };
45
+
46
+ const Icon = icon;
47
+
48
+ return (
49
+ <div className={style.wrapper}>
50
+ <Icon className={style.icon} size={18}/>
51
+ <input
52
+ // required=""
53
+ // minLength="3"
54
+ // maxLength="15"
55
+ title="Le pseudo doit avoir une longueur comprise entre 3 et 15 caractères."
56
+ // tabIndex="10"
57
+ type={type}
58
+ id={id}
59
+ name={name}
60
+ value={value}
61
+ placeholder={placeholder}
62
+ // onChange={handleInputChange}
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
+ };
src/components/input/style.module.scss ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .wrapper {
2
+ position: relative;
3
+ margin-bottom: 0.9375rem
4
+ }
5
+
6
+ .icon {
7
+ position: absolute;
8
+ top: .625rem;
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;
20
+ font-weight: 400;
21
+ line-height: 1.5;
22
+ color: var(--input-text-color);
23
+ background-color: var(--input-bg-color);
24
+ background-clip: padding-box;
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
+
32
+ padding-top: 0;
33
+ padding-right: .3125rem;
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
+
41
+ &::placeholder {
42
+ color: var(--input-placeholder-color);
43
+ font-weight: 400;
44
+ }
45
+ }
src/components/layout/index.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}>
15
+ {props.breadcrumbs}
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>
23
+ </div>
24
+ </nav>
25
+ <h2>{props.title}</h2>
26
+ {props.children}
27
+ </div>
28
+ )
29
+ }
src/components/layout/style.module.scss ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
12
+ margin-left: auto;
13
+
14
+ a:hover {
15
+ color: var(--text-muted-hover-color);
16
+ }
17
+ }
18
+
19
+ //.title {
20
+ // font-size: 1.59375rem;
21
+ // line-height: 1.25;
22
+ // font-weight: 500;
23
+ // color: var(--text-color);
24
+ // padding-bottom: 1.5rem;
25
+ //}
src/components/preview/index.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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",
7
+ // img: "img",
8
+ // }
9
+ //
10
+ // type Segment = {
11
+ // type: keyof typeof blocks,
12
+ // content: string,
13
+ // }
14
+
15
+
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 (
34
+ <div className={style.wrapper} dangerouslySetInnerHTML={{__html: html}}/>
35
+ )
36
+ //
37
+ // return (
38
+ // <div className={style.wrapper}>
39
+ // {segments.map(seg => {
40
+ // if (seg.type === blocks.text) {
41
+ // return seg.content
42
+ // }
43
+ //
44
+ // if (seg.type === blocks.img) {
45
+ // return <img width="68" height="51" alt="noelshak" src={seg.content}/>
46
+ // }
47
+ // })}
48
+ // </div>
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(
96
+ // Object.keys(smileysMap).map(s => (
97
+ // "(?:(?:^| )" + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ")"
98
+ // )).join("|"), "gm"
99
+ // ), `<img src="${maping[1]}" width="16" height="16" alt=""/>`]
100
+ // })
101
+ // (() => {
102
+ // new RegExp(
103
+ // smileysMap.map((mapping) => (
104
+ // "(?:(?:^| )" + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ")"
105
+ // )).join("|"), "gm"
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 {
141
+ // unsafe.re
142
+ return unsafe
143
+ .replace(/&/g, "&amp;")
144
+ .replace(/</g, "&lt;")
145
+ .replace(/>/g, "&gt;")
146
+ .replace(/"/g, "&quot;")
147
+ .replace(/'/g, "&#039;");
148
+ }
149
+
150
+ // function parseRaw(raw: string): Segment[] {
151
+ // // Regex pour capturer les URLs (HTTP, HTTPS)
152
+ // const urlRegex = /(https?:\/\/[^\s]+)/g;
153
+ //
154
+ // // Découper le texte en utilisant la regex, en gardant les URLs comme blocs séparés
155
+ // return raw.split(urlRegex).filter(seg => seg !== "").map(seg => {
156
+ // return {
157
+ // type: seg.includes(".png") || seg.includes(".jpg") ? blocks.img : blocks.text,
158
+ // content: seg,
159
+ // }
160
+ // });
161
+ // }
src/components/preview/style.module.scss ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .wrapper {
2
+ //white-space: pre-line;
3
+
4
+ :global {
5
+ .bloc-spoil-jv {
6
+ //margin-bottom: 0.9375rem;
7
+ margin-bottom: 0;
8
+ display: inline;
9
+
10
+ .open-spoil {
11
+ position: absolute;
12
+ left: -999rem;
13
+ }
14
+
15
+ .barre-head {
16
+ //height: 1.5rem;
17
+ //line-height: 1.5rem;
18
+ //cursor: pointer;
19
+ //position: relative;
20
+ //display: block;
21
+
22
+ height: auto;
23
+ line-height: inherit;
24
+ text-align: center;
25
+ cursor: pointer;
26
+ position: relative;
27
+ display: inline;
28
+
29
+ .txt-spoil {
30
+ //position: absolute;
31
+ //top: 0;
32
+ //left: 0;
33
+ //width: 4.6875rem;
34
+ //font-size: .9285em;
35
+ //text-align: center;
36
+ //color: #fff;
37
+ //text-transform: uppercase;
38
+ //background: #fd374e;
39
+ //font-weight: 700;
40
+ //display: block;
41
+ //border-radius: 0.25rem;
42
+
43
+ position: static;
44
+ display: inline;
45
+ padding: 0.125rem 1.25rem;
46
+ line-height: inherit;
47
+ top: 0;
48
+ left: 0;
49
+ width: 4.6875rem;
50
+ font-size: .9285em;
51
+ text-align: center;
52
+ color: #fff;
53
+ text-transform: uppercase;
54
+ background: #fd374e;
55
+ font-weight: 700;
56
+ //display: block;
57
+ border-radius: 0.25rem;
58
+ }
59
+ }
60
+
61
+ .contenu-spoil {
62
+ background: #f4d6da;
63
+ color: #212121;
64
+ padding: 0.625rem;
65
+ display: none;
66
+ //text-align: justify;
67
+ text-align: left;
68
+ overflow: hidden;
69
+ }
70
+
71
+ .open-spoil:checked ~ .contenu-spoil {
72
+ display: inline;
73
+ }
74
+ }
75
+ }
76
+
77
+ blockquote {
78
+ text-align: left;
79
+ //margin-bottom: 0.9375rem;
80
+ border-left: 0.3125rem solid rgba(0, 0, 0, .2);
81
+ color: var(--text-blockquote-color);
82
+ //margin-left: 0.9375rem;
83
+ padding: 0.625rem;
84
+ background: none;
85
+ font-style: normal;
86
+ font-size: inherit;
87
+ margin: 0 0 0.9375rem 1rem;
88
+ }
89
+
90
+ a {
91
+ color: var(--link-color);
92
+ text-decoration: none;
93
+
94
+ &:hover {
95
+ color: var(--text-hover-secondary);
96
+ text-decoration: none;
97
+ }
98
+ }
99
+ }
100
+
101
+ .img {
102
+
103
+ }
src/components/settings/index.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }) {
13
+ // const [settings, setSettings] = useState(fetchSettings)
14
+ //
15
+ // useEffect(() => {
16
+ // saveSettings(settings);
17
+ // }, [settings]);
18
+
19
+ return <div>
20
+ <form>
21
+ <FormGroup>
22
+ <label htmlFor="api">API</label>
23
+ <Input
24
+ type="text"
25
+ placeholder="URl d'API ex: https://ouruq7zepnehg2-5000.proxy.runpod.net/"
26
+ icon={Link}
27
+ value={props.settings.apiURL}
28
+ onChange={(v) => props.setSettings({...props.settings, apiURL: v as string})}
29
+ />
30
+ </FormGroup>
31
+ <FormGroup>
32
+ <label for="temperature">Temperature</label>
33
+ <Slider
34
+ name="temperature"
35
+ value={props.settings.temperature}
36
+ onChange={(v) => props.setSettings({...props.settings, temperature: v})}
37
+ min={0}
38
+ max={5}
39
+ step={0.1}
40
+ />
41
+ </FormGroup>
42
+ <Button
43
+ onClick={() => {
44
+ history.go(-1)
45
+ }}
46
+ >
47
+ Retour
48
+ </Button>
49
+ </form>
50
+ </div>
51
+ }
src/components/settings/style.module.scss ADDED
File without changes
src/components/slider/index.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // import * as RadixSlider from "@radix-ui/react-slider";
2
+ // import style from "./style.module.scss";
3
+ // import {useState} from "preact/hooks";
4
+ //
5
+ // export function Slider(props: {
6
+ // value: number,
7
+ // min: number,
8
+ // max: number,
9
+ // step: number,
10
+ // onChange: (value: number) => void,
11
+ // className?: string,
12
+ // disabled?: boolean,
13
+ // }) {
14
+ // const [value, setValue] = useState(1)
15
+ //
16
+ // return (
17
+ // <div className={style.rangeSlider} style={`--min:0; --max:5; --step:0.5; --value:${value}; --text-value:"${value}"`}>
18
+ // <input
19
+ // type="range"
20
+ // min="0"
21
+ // max="5"
22
+ // step="0.1"
23
+ // value={value}
24
+ // onInput={(e) => setValue(Number((e.target as HTMLInputElement).value))}
25
+ // // onInput="this.parentNode.style.setProperty('--value',this.value); this.parentNode.style.setProperty('--text-value', JSON.stringify(this.value))"
26
+ // />
27
+ // <output></output>
28
+ // <div className={style.progress}></div>
29
+ // </div>
30
+ // )
31
+ // }
32
+
33
+ import style from "./style.module.scss";
34
+
35
+ export function Slider(props: {
36
+ name: string,
37
+ value: number,
38
+ min: number,
39
+ max: number,
40
+ step: number,
41
+ onChange: (value: number) => void,
42
+ className?: string,
43
+ disabled?: boolean,
44
+ }) {
45
+ return (
46
+ <div className={style.rangeSlider} style={`--min:${props.min}; --max:${props.max}; --step:${Math.max(props.step, 0.5)}; --value:${props.value}; --text-value:"${props.value}";`}>
47
+ <input
48
+ name={props.name}
49
+ type="range"
50
+ min={props.min}
51
+ max={props.max}
52
+ step={props.step}
53
+ value={props.value}
54
+ onInput={(e) => props.onChange(Number((e.target as HTMLInputElement).value))}
55
+ />
56
+ <output></output>
57
+ <div className={style.progress}></div>
58
+ </div>
59
+ )
60
+ }
src/components/slider/style.module.scss ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .rangeSlider {
2
+ //width: Clamp(300px, 50vw, 800px);
3
+ //min-width: 200px;
4
+
5
+ --primary-color: var(--text-secondary);
6
+
7
+ --value-offset-y: var(--ticks-gap);
8
+ --value-active-color: white;
9
+ --value-background: transparent;
10
+ --value-background-hover: var(--primary-color);
11
+ --value-font: 700 12px/1 Arial;
12
+
13
+ --fill-color: var(--primary-color);
14
+ --progress-background: #eee;
15
+ --progress-radius: 20px;
16
+ --track-height: calc(var(--thumb-size) / 2);
17
+
18
+ --min-max-font: 12px Arial;
19
+ --min-max-opacity: 0.5;
20
+ --min-max-x-offset: 50%;
21
+
22
+ --thumb-size: 20px;
23
+ --thumb-color: white;
24
+ --thumb-shadow: 0 0 3px rgba(0, 0, 0, 0.4), 0 0 1px rgba(0, 0, 0, 0.5) inset,
25
+ 0 0 0 99px var(--thumb-color) inset;
26
+
27
+ --thumb-shadow-active: 0 0 0 calc(var(--thumb-size) / 4) inset var(--thumb-color),
28
+ 0 0 0 99px var(--primary-color) inset, 0 0 3px rgba(0, 0, 0, 0.4);
29
+
30
+ --thumb-shadow-hover: var(--thumb-shadow);
31
+
32
+ --ticks-thickness: 1px;
33
+ --ticks-height: 5px;
34
+ --ticks-gap: var(
35
+ --ticks-height,
36
+ 0
37
+ ); // vertical space between the ticks and the progress bar
38
+ --ticks-color: silver;
39
+
40
+ // ⚠️ BELOW VARIABLES SHOULD NOT BE CHANGED
41
+ --step: 1;
42
+ --ticks-count: Calc(var(--max) - var(--min)) / var(--step);
43
+ --maxTicksAllowed: 30;
44
+ --too-many-ticks: Min(1, Max(var(--ticks-count) - var(--maxTicksAllowed), 0));
45
+ --x-step: Max(
46
+ var(--step),
47
+ var(--too-many-ticks) * (var(--max) - var(--min))
48
+ ); // manipulate the number of steps if too many ticks exist, so there would only be 2
49
+ --tickInterval: 100/ ((var(--max) - var(--min)) / var(--step)) * var(--tickEvery, 1);
50
+ --tickIntervalPerc: calc(
51
+ (100% - var(--thumb-size)) / ((var(--max) - var(--min)) / var(--x-step)) *
52
+ var(--tickEvery, 1)
53
+ );
54
+
55
+ --value-a: Clamp(
56
+ var(--min),
57
+ var(--value, 0),
58
+ var(--max)
59
+ ); // default value ("--value" is used in single-range markup)
60
+ --value-b: var(--value, 0); // default value
61
+ --text-value-a: var(--text-value, "");
62
+
63
+ --completed-a: calc(
64
+ (var(--value-a) - var(--min)) / (var(--max) - var(--min)) * 100
65
+ );
66
+ --completed-b: calc(
67
+ (var(--value-b) - var(--min)) / (var(--max) - var(--min)) * 100
68
+ );
69
+ --ca: Min(var(--completed-a), var(--completed-b));
70
+ --cb: Max(var(--completed-a), var(--completed-b));
71
+
72
+ // breakdown of the below super-complex brain-breaking CSS math:
73
+ // "clamp" is used to ensure either "-1" or "1"
74
+ // "calc" is used to inflat the outcome into a huge number, to get rid of any value between -1 & 1
75
+ // if absolute diff of both completed % is above "5" (%)
76
+ // ".001" bumps the value just a bit, to avoid a scenario where calc resulted in "0" (then clamp will also be "0")
77
+ --thumbs-too-close: Clamp(
78
+ -1,
79
+ 1000 * (Min(1, Max(var(--cb) - var(--ca) - 5, -1)) + 0.001),
80
+ 1
81
+ );
82
+
83
+ @mixin thumb {
84
+ appearance: none;
85
+ height: var(--thumb-size);
86
+ width: var(--thumb-size);
87
+ //transform: var(--thumb-transform);
88
+ transform: translateY(-4px);
89
+ border-radius: var(--thumb-radius, 50%);
90
+ background: var(--thumb-color);
91
+ box-shadow: var(--thumb-shadow);
92
+ border: none;
93
+ pointer-events: auto;
94
+ transition: 0.1s;
95
+ }
96
+
97
+ display: inline-block;
98
+ height: Max(var(--track-height), var(--thumb-size));
99
+ // margin: calc((var(--thumb-size) - var(--track-height)) * -.25) var(--thumb-size) 0;
100
+ background: linear-gradient(
101
+ to right,
102
+ var(--ticks-color) var(--ticks-thickness),
103
+ transparent 1px
104
+ ) repeat-x;
105
+ background-size: var(--tickIntervalPerc) var(--ticks-height);
106
+ background-position-x: calc(
107
+ var(--thumb-size) / 2 - var(--ticks-thickness) / 2
108
+ );
109
+ background-position-y: var(--flip-y, bottom);
110
+
111
+ padding-bottom: var(--flip-y, var(--ticks-gap));
112
+ padding-top: calc(var(--flip-y) * var(--ticks-gap));
113
+ margin-top: 1rem;
114
+ margin-bottom: 2rem;
115
+
116
+ position: relative;
117
+ z-index: 1;
118
+
119
+ &[data-ticks-position="top"] {
120
+ --flip-y: 1;
121
+ }
122
+
123
+ // mix/max texts
124
+ &::before,
125
+ &::after {
126
+ --offset: calc(var(--thumb-size) / 2);
127
+ content: counter(x);
128
+ display: var(--show-min-max, block);
129
+ font: var(--min-max-font);
130
+ position: absolute;
131
+ bottom: var(--flip-y, -2.5ch);
132
+ top: calc(-2.5ch * var(--flip-y));
133
+ opacity: var(--min-max-opacity);
134
+ //transform: translateX(calc(var(--min-max-x-offset) * var(--before, -1) * -1)) scale(var(--at-edge));
135
+ transform: translateX(calc(var(--min-max-x-offset) * var(--before, -1) * -1));
136
+ pointer-events: none;
137
+ }
138
+
139
+ &::before {
140
+ --before: 1;
141
+ counter-reset: x var(--min);
142
+ left: var(--offset);
143
+ }
144
+
145
+ &::after {
146
+ counter-reset: x var(--max);
147
+ right: var(--offset);
148
+ }
149
+
150
+ &__values {
151
+ position: relative;
152
+ top: 50%;
153
+ line-height: 0;
154
+ text-align: justify;
155
+ width: 100%;
156
+ pointer-events: none;
157
+ margin: 0 auto;
158
+ z-index: 5;
159
+
160
+ // trick so "justify" will work
161
+ &::after {
162
+ content: "";
163
+ width: 100%;
164
+ display: inline-block;
165
+ height: 0;
166
+ background: red;
167
+ }
168
+ }
169
+
170
+ .progress {
171
+ --start-end: calc(var(--thumb-size) / 2);
172
+ --clip-end: calc(100% - (var(--cb)) * 1%);
173
+ --clip-start: calc(var(--ca) * 1%);
174
+ --clip: inset(-20px var(--clip-end) -20px var(--clip-start));
175
+ position: absolute;
176
+ left: calc(var(--start-end) - 10px);
177
+ right: calc(var(--start-end) - 10px);
178
+ top: calc(
179
+ var(--ticks-gap) * var(--flip-y, 0) + var(--thumb-size) / 2 -
180
+ var(--track-height)
181
+ );
182
+ // transform: var(--flip-y, translateY(-50%) translateZ(0));
183
+ height: calc(var(--track-height));
184
+ background: var(--progress-background, #eee);
185
+ pointer-events: none;
186
+ z-index: -1;
187
+ border-radius: var(--progress-radius);
188
+
189
+ // fill area
190
+ &::before {
191
+ content: "";
192
+ position: absolute;
193
+ // left: Clamp(0%, calc(var(--ca) * 1%), 100%); // confine to 0 or above
194
+ // width: Min(100%, calc((var(--cb) - var(--ca)) * 1%)); // confine to maximum 100%
195
+ left: 0;
196
+ right: 0;
197
+ clip-path: var(--clip);
198
+ top: 0;
199
+ bottom: 0;
200
+ background: var(--fill-color, black);
201
+ box-shadow: var(--progress-flll-shadow);
202
+ z-index: 1;
203
+ border-radius: inherit;
204
+ }
205
+
206
+ // shadow-effect
207
+ &::after {
208
+ content: "";
209
+ position: absolute;
210
+ top: 0;
211
+ right: 0;
212
+ bottom: 0;
213
+ left: 0;
214
+ box-shadow: var(--progress-shadow);
215
+ pointer-events: none;
216
+ border-radius: inherit;
217
+ }
218
+ }
219
+
220
+ & > input {
221
+ -webkit-appearance: none;
222
+ width: 100%;
223
+ height: var(--thumb-size);
224
+ margin: 0;
225
+ position: absolute;
226
+ left: 0;
227
+ top: calc(
228
+ 50% - Max(var(--track-height), var(--thumb-size)) / 2 +
229
+ calc(var(--ticks-gap) / 2 * var(--flip-y, -1))
230
+ );
231
+ cursor: -webkit-grab;
232
+ cursor: grab;
233
+ outline: none;
234
+ background: none;
235
+
236
+ &:not(:only-of-type) {
237
+ pointer-events: none;
238
+ }
239
+
240
+ &::-webkit-slider-thumb {
241
+ @include thumb;
242
+ }
243
+
244
+ &::-moz-range-thumb {
245
+ @include thumb;
246
+ }
247
+
248
+ &::-ms-thumb {
249
+ @include thumb;
250
+ }
251
+
252
+ &:hover {
253
+ --thumb-shadow: var(--thumb-shadow-hover);
254
+
255
+ & + output {
256
+ --value-background: var(--value-background-hover);
257
+ --y-offset: -8px;
258
+ color: var(--value-active-color);
259
+ box-shadow: 0 0 0 3px var(--value-background);
260
+ }
261
+ }
262
+
263
+ &:active {
264
+ --thumb-shadow: var(--thumb-shadow-active);
265
+ cursor: grabbing;
266
+ z-index: 2; // when sliding left thumb over the right or vice-versa, make sure the moved thumb is on top
267
+ + output {
268
+ transition: 0s;
269
+ }
270
+ }
271
+
272
+ &:nth-of-type(1) {
273
+ --is-left-most: Clamp(0, (var(--value-a) - var(--value-b)) * 99999, 1);
274
+
275
+ & + output {
276
+ &:not(:only-of-type) {
277
+ --flip: calc(var(--thumbs-too-close) * -1);
278
+ }
279
+
280
+ --value: var(--value-a);
281
+ --x-offset: calc(var(--completed-a) * -1%);
282
+
283
+ &::after {
284
+ content: var(--prefix, "") var(--text-value-a) var(--suffix, "");
285
+ }
286
+ }
287
+ }
288
+
289
+ &:nth-of-type(2) {
290
+ --is-left-most: Clamp(0, (var(--value-b) - var(--value-a)) * 99999, 1);
291
+
292
+ & + output {
293
+ --value: var(--value-b);
294
+ }
295
+ }
296
+
297
+ // non-multiple range should not clip start of progress bar
298
+ &:only-of-type {
299
+ ~ .progress {
300
+ --clip-start: 0;
301
+ }
302
+ }
303
+
304
+ & + output {
305
+ --flip: -1;
306
+ --x-offset: calc(var(--completed-b) * -1%);
307
+ --pos: calc(
308
+ ((var(--value) - var(--min)) / (var(--max) - var(--min))) * 100%
309
+ );
310
+
311
+ pointer-events: none;
312
+ position: absolute;
313
+ z-index: 5;
314
+ background: var(--value-background);
315
+ border-radius: 10px;
316
+ //padding: 2px 4px;
317
+ padding: 0 4px;
318
+ left: var(--pos);
319
+ transform: translate(var(--x-offset), calc(150% * var(--flip) - (var(--y-offset, 0px) + var(--value-offset-y)) * var(--flip)));
320
+ transition: all 0.12s ease-out, left 0s;
321
+
322
+ &::after {
323
+ content: var(--prefix, "") var(--text-value-b) var(--suffix, "");
324
+ font: var(--value-font);
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+
src/components/spinner/index.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "classnames"
2
+ import style from "./style.module.scss"
3
+
4
+ export function Spinner(props: {className?: string}) {
5
+ return (
6
+ <div className={cn(style.spinnerSquare, props.className)}>
7
+ <div className={style.square1}></div>
8
+ <div className={style.square2}></div>
9
+ <div className={style.square3}></div>
10
+ </div>
11
+ )
12
+ }
src/components/spinner/style.module.scss ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
15
+ .square1 {
16
+ animation: square-anim 1200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s infinite;
17
+ }
18
+
19
+ .square2 {
20
+ animation: square-anim 1200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) 200ms infinite;
21
+ }
22
+
23
+ .square3 {
24
+ animation: square-anim 1200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) 400ms infinite;
25
+ }
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
+ }
src/components/topic/index.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}>
25
+ <FormGroup>
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}
36
+ />
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}>
57
+ <div className={style.postHeader}>
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
+ }
src/components/topic/style.module.scss ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .post {
2
+ margin-bottom: 0.9375rem;
3
+ font-size: .9375rem;
4
+ line-height: 1.5;
5
+ background: var(--block-bg-color);
6
+ border: 0.0625rem solid var(--border-color);
7
+ border-radius: 0.5rem;
8
+ overflow: hidden;
9
+ padding: 0.75rem;
10
+ }
11
+
12
+ .postHeader {
13
+ border-bottom: 0.0625rem dotted var(--border-color);
14
+ height: 3.625rem;
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
+
25
+ .avatar {
26
+ grid-area: avatar;
27
+ height: 3rem;
28
+ width: 3rem;
29
+ object-fit: cover;
30
+ border-radius: 50%;
31
+ }
32
+
33
+ .user {
34
+ grid-area: user;
35
+ font-size: 1.0625rem;
36
+ font-weight: 500;
37
+ font-family: "Roboto", Arial, Helvetica, sans-serif;
38
+ line-height: 1.25;
39
+ }
40
+
41
+ .date {
42
+ grid-area: date;
43
+ color: var(--link-color);
44
+ text-decoration: none;
45
+ font-size: .8125rem;
46
+ }
47
+
48
+ .generationSettings {
49
+ display: grid;
50
+ grid-template-columns: 1fr 1fr;
51
+ }
src/components/topics/index.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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} latestGeneratedTopicId={props.latestGeneratedTopicId}/>
46
+ }
47
+ <div>
48
+ <h2>Nouveau topic</h2>
49
+ <div className={style.generationSettings}>
50
+ <FormGroup>
51
+ <label for="postCount">Nombre de posts</label>
52
+ <Slider
53
+ name="postCount"
54
+ value={props.settings.postCount}
55
+ // onChange={(v) => props.setSettings({...props.settings, temperature: v as number})}
56
+ // onChange={setGenerationPostCount}
57
+ onChange={(v) => props.setSettings({...props.settings, postCount: v})}
58
+ min={1}
59
+ max={10}
60
+ step={1}
61
+ />
62
+ </FormGroup>
63
+ </div>
64
+ <Button
65
+ onClick={() => props.generateTopic(props.settings.postCount)}
66
+ secondary={true}
67
+ loading={props.pendingGeneration}
68
+ >
69
+ Générer
70
+ </Button>
71
+ </div>
72
+ <hr/>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ function List(props: {
78
+ topics: Topic[],
79
+ setRoute: RouteSetter,
80
+ latestGeneratedTopicId: string|null,
81
+ }) {
82
+ return (
83
+ <ul className={style.list}>
84
+ <li className={style.head}>
85
+ <span>Sujet</span>
86
+ <span>Auteur</span>
87
+ <span>NB</span>
88
+ <span>Dernier msg</span>
89
+ </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
+ }
src/components/topics/style.module.scss ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .spinner {
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;
11
+ //margin-bottom: 1.5rem;
12
+ background: var(--block-bg-color);
13
+ border: .0625rem solid var(--border-color);
14
+ border-radius: 0.75rem;
15
+ width: 100%;
16
+ padding: 0;
17
+ overflow: hidden;
18
+ margin-bottom: 1.5rem;
19
+
20
+ > li {
21
+ display: contents;
22
+ list-style: none;
23
+ padding: 0;
24
+ margin: 0;
25
+
26
+ > span {
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;
33
+ display: -webkit-box;
34
+ -webkit-line-clamp: var(--topic-title-lines);
35
+ -webkit-box-orient: vertical;
36
+ //margin-left: var(--topic-gap);
37
+ color: var(--link-color);
38
+ text-decoration: none;
39
+ font-size: var(--topic-title-font-size);
40
+ font-weight: var(--topic-title-font-weight);
41
+
42
+ &:hover {
43
+ color: var(--text-hover-secondary);
44
+ }
45
+ }
46
+ }
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;
65
+ }
66
+ }
67
+
68
+ .generationSettings {
69
+ display: grid;
70
+ grid-template-columns: 1fr 1fr;
71
+ }
src/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import {render, h} from "preact";
2
+ import {App} from "./app";
3
+
4
+ render(h(App, null), document.getElementById('app'))
src/reset.scss ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /***
2
+ The new CSS reset - version 1.11.3 (last updated 25.08.2024)
3
+ GitHub page: https://github.com/elad2412/the-new-css-reset
4
+ ***/
5
+
6
+ /*
7
+ Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property
8
+ - The "symbol *" part is to solve Firefox SVG sprite bug
9
+ - The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
10
+ */
11
+ *:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)) {
12
+ all: unset;
13
+ display: revert;
14
+ }
15
+
16
+ /* Preferred box-sizing value */
17
+ *,
18
+ *::before,
19
+ *::after {
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ /* Fix mobile Safari increase font-size on landscape mode */
24
+ html {
25
+ -moz-text-size-adjust: none;
26
+ -webkit-text-size-adjust: none;
27
+ text-size-adjust: none;
28
+ }
29
+
30
+ /* Reapply the pointer cursor for anchor tags */
31
+ a, button {
32
+ cursor: revert;
33
+ }
34
+
35
+ /* Remove list styles (bullets/numbers) */
36
+ ol, ul, menu, summary {
37
+ list-style: none;
38
+ }
39
+
40
+ /* Firefox: solve issue where nested ordered lists continue numbering from parent (https://bugzilla.mozilla.org/show_bug.cgi?id=1881517) */
41
+ ol {
42
+ counter-reset: revert;
43
+ }
44
+
45
+ /* For images to not be able to exceed their container */
46
+ img {
47
+ max-inline-size: 100%;
48
+ max-block-size: 100%;
49
+ }
50
+
51
+ /* removes spacing between cells in tables */
52
+ table {
53
+ border-collapse: collapse;
54
+ }
55
+
56
+ /* Safari - solving issue when using user-select:none on the <body> text input doesn't working */
57
+ input, textarea {
58
+ -webkit-user-select: auto;
59
+ }
60
+
61
+ /* revert the 'white-space' property for textarea elements on Safari */
62
+ textarea {
63
+ white-space: revert;
64
+ }
65
+
66
+ /* minimum style to allow to style meter element */
67
+ meter {
68
+ -webkit-appearance: revert;
69
+ appearance: revert;
70
+ }
71
+
72
+ /* preformatted text - use only for this feature */
73
+ :where(pre) {
74
+ all: revert;
75
+ box-sizing: border-box;
76
+ }
77
+
78
+ /* reset default text opacity of input placeholder */
79
+ ::placeholder {
80
+ color: unset;
81
+ }
82
+
83
+ /* fix the feature of 'hidden' attribute.
84
+ display:revert; revert to element instead of attribute */
85
+ :where([hidden]) {
86
+ display: none;
87
+ }
88
+
89
+ /* revert for bug in Chromium browsers
90
+ - fix for the content editable attribute will work properly.
91
+ - webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/
92
+ :where([contenteditable]:not([contenteditable="false"])) {
93
+ -moz-user-modify: read-write;
94
+ -webkit-user-modify: read-write;
95
+ overflow-wrap: break-word;
96
+ -webkit-line-break: after-white-space;
97
+ -webkit-user-select: auto;
98
+ }
99
+
100
+ /* apply back the draggable feature - exist only in Chromium and Safari */
101
+ :where([draggable="true"]) {
102
+ -webkit-user-drag: element;
103
+ }
104
+
105
+ /* Revert Modal native behavior */
106
+ //noinspection CssInvalidPseudoSelector
107
+ :where(dialog:modal) {
108
+ all: revert;
109
+ box-sizing: border-box;
110
+ }
111
+
112
+ /* Remove details summary webkit styles */
113
+ ::-webkit-details-marker {
114
+ display: none;
115
+ }
src/style.module.scss ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .header {
2
+ background-color: var(--header-bg-color);
3
+ color: var(--text-color);
4
+ }
5
+
6
+ .logo {
7
+ font-size: 2rem;
8
+ font-weight: 700;
9
+ }
10
+
11
+ .main {
12
+
13
+ }
src/styles.scss ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @use "./reset";
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');
4
+
5
+ // JVC color theme
6
+ :root {
7
+ color-scheme: dark;
8
+ --body-bg: #22252a;
9
+ --text-primary: #3d87f5;
10
+ --text-hover-primary: #5596f6;
11
+ --text-secondary: #f66031;
12
+ --text-hover-secondary: #f7734a;
13
+ --text-danger: #e4606d;
14
+ --text-hover-danger: #dc3545;
15
+ --text-dark: #e0e0e0;
16
+ --text-hover-dark: #fff;
17
+ --text-light: #000;
18
+ --bg-color-light: #272a30;
19
+ --bg-color-light-transparent: rgba(39, 42, 48, 0);
20
+ --bg-color: #22252a;
21
+ --text-color: #f2f2f2;
22
+ --border-color: #4a4c4f;
23
+ --border-even-color: #454f5e;
24
+ --border-highlighted-color: #765d3f;
25
+ --block-bg-color: #2e3238;
26
+ --block-even-bg-color: #363e49;
27
+ --block-highlighted-bg-color: #594126;
28
+ --block-bg-color-hover: #353941;
29
+ --block-bg-color-hover-dark: #454b54;
30
+ --text-muted-color: #9e9e9e;
31
+ --text-muted-hover-color: #bababa;
32
+ --text-blockquote-color: #c7c7d1;
33
+ --link-color: #7dc3f7;
34
+ --link-hover-bg-color: #4c5767;
35
+ --header-bg-color: #18191b;
36
+ --header-bottom-bg-color: #000;
37
+ --header-bottom-bg-color-hover: #303236;
38
+ --video-footer-bg-color: #1d1e20;
39
+ --layout-bg-color: #292d32;
40
+ --disabled-bg-color: #1d1e20;
41
+ --disabled-text-color: #787e87;
42
+ --disabled-border-color: #787e87;
43
+ --ad-bg-color: #18191b;
44
+ --ad-border-color: #000;
45
+ --game-header-bg-color: #2c2f35;
46
+ --bg-light: #2c2f35;
47
+ --game-tab-hover-bg-color: #17191c;
48
+ --price-color: #9fafc6;
49
+ --blue-light: #414a58;
50
+ --live-color: #ff4d4d;
51
+ --live-hover-color: #ff1a1a;
52
+ --gauge-bg-color: #616161;
53
+ --blue-gray-color: #9fafc6;
54
+ --message-delete: #104e60;
55
+ --message-delete-border: #092a34;
56
+ --message-delete-gta: #46435b;
57
+ --message-delete-gta-border: #191820;
58
+ --input-bg-color: #303236;
59
+ --input-border-color: #636569;
60
+ --input-text-color: #f2f2f2;
61
+ --input-placeholder-color: #616161;
62
+ --form-select-indicator: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath stroke=%27%23e0e0e0%27 stroke-width=%272px%27 d=%27M2 5l6 6 6-6%27 fill=%27none%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e");
63
+ --form-switch-bg-image: url("data:image/svg+xml,<svg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%27-4 -4 8 8%27><circle r=%273%27 fill=%27%23636569%27/></svg>");
64
+ --admin-color: #f24444;
65
+ --modo-color: #42b228;
66
+ --ad-placeholder-opacity: 0.4;
67
+ --alert-brightness: 0.85;
68
+ --filters-bg-color: #16191d;
69
+ --gray-100: #f8f9fa;
70
+ --gray-200: #e9ecef;
71
+ --gray-300: #dee2e6;
72
+ --gray-400: #ced4da;
73
+ --gray-500: #adb5bd;
74
+ --gray-600: #6c757d;
75
+ --gray-700: #495057;
76
+ --gray-800: #343a40;
77
+ --gray-900: #212529;
78
+ --white-rgb: #fff;
79
+ --black-rgb: #000;
80
+ --body-color-rgb: var(--text-color);
81
+ --body-bg-rgb: var(--body-bg);
82
+ --font-sans-serif: "Roboto", Arial, Helvetica, sans-serif;
83
+ --font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
84
+ --gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
85
+ --root-font-size: 1rem;
86
+ --body-font-family: "Roboto", Arial, Helvetica, sans-serif;
87
+ --body-font-size: 0.9375rem;
88
+ --body-font-weight: 400;
89
+ --body-line-height: 1.5;
90
+ --body-color: var(--text-color);
91
+ --border-width: 0.0625rem;
92
+ --border-style: solid;
93
+ --border-color-translucent: rgba(0, 0, 0, 0.175);
94
+ --border-radius: 0.75rem;
95
+ --border-radius-sm: 0.5rem;
96
+ --border-radius-lg: 0.75rem;
97
+ --border-radius-xl: 1rem;
98
+ --border-radius-2xl: 2rem;
99
+ --border-radius-pill: 50rem;
100
+ --link-hover-color: var(--text-hover-secondary);
101
+ --code-color: #d63384;
102
+ --highlight-bg: #fff3cd;
103
+ --topic-font-size: 0.8125rem;
104
+ --topic-font-weight: 700;
105
+ --topic-line-height: 1.25;
106
+ --topic-gap: 0.625rem;
107
+ --topic-title-font-size: 0.9375rem;
108
+ --topic-title-font-weight: 500;
109
+ }
110
+
111
+ body {
112
+ margin: 0;
113
+ font-family: var(--body-font-family), sans-serif;
114
+ font-size: var(--body-font-size);
115
+ font-weight: var(--body-font-weight);
116
+ line-height: var(--body-line-height);
117
+ color: var(--body-color);
118
+ text-align: var(--body-text-align);
119
+ background-color: var(--body-bg);
120
+ -webkit-text-size-adjust: 100%;
121
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
122
+ }
123
+
124
+ hr {
125
+ //height: 0.0625rem;
126
+ height: 1px;
127
+ width: 100%;
128
+ background-color: var(--border-color);
129
+ display: block;
130
+ margin: 1.5rem 0;
131
+ }
132
+
133
+ h2 {
134
+ font-size: 1.59375rem;
135
+ line-height: 1.25;
136
+ font-weight: 500;
137
+ color: var(--text-color);
138
+ padding-bottom: 1.5rem;
139
+ }
src/types.d.ts ADDED
File without changes
src/utils/dates.ts ADDED
@@ -0,0 +1,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
+ console.log("iso8601ToTokens", iso8601)
4
+
5
+ const matches = iso8601.match(iso8601ToFrenchRegex);
6
+
7
+ const year = matches[1];
8
+ const month = months[parseInt(matches[2], 10) - 1];
9
+ const day = matches[3];
10
+ const hours = matches[4];
11
+ const minutes = matches[5];
12
+ const seconds = matches[6];
13
+
14
+ return `${day} ${month} ${year} à ${hours}:${minutes}:${seconds}`;
15
+ }
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) {
31
+ throw new Error('Invalid date format');
32
+ }
33
+
34
+ const [, day, month, year, hours, minutes, seconds] = match;
35
+
36
+ // Convert the month name to a numeric format
37
+ const monthNumber = (months.indexOf(month) + 1).toString();
38
+
39
+ if (!monthNumber) {
40
+ throw new Error('Invalid month name');
41
+ }
42
+
43
+ // Construct the ISO 8601 formatted string
44
+ const isoDate = `${year}-${monthNumber.padStart(2, '0')}-${day.padStart(2, '0')}T${hours}:${minutes}:${seconds}`;
45
+ return isoDate;
46
+ }
47
+
48
+ const months = [
49
+ "janvier", "février", "mars", "avril", "mai", "juin",
50
+ "juillet", "août", "septembre", "octobre", "novembre", "décembre"
51
+ ];
src/utils/model.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ };
17
+
18
+ // const splits = tokens.split("<|end_of_post|>")
19
+ // console.log("Splits:")
20
+ // console.log(splits);
21
+
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
+ }
35
+
36
+ // topic.posts.push(tokensToPosts());
37
+ topic.posts = topic.posts.concat(tokensToPosts(postTokens));
38
+ }
39
+
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|>")) {
47
+
48
+ // TODO: remove the last instead of doing this, because the last can be incomplete
49
+ if(postTokens.length < 1) {
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
+ }
71
+
72
+ return posts;
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
+ }
80
+
81
+ const tokenizedPosts = topic.posts.map(post => tokenizePost(post, topic.posts[0].user)).flat().join("");
82
+
83
+ // console.log("Tokenized posts:")
84
+ // console.log(tokenizedPosts)
85
+
86
+ let lines = [
87
+ "<|start_header_id|><|sujet|><|end_header_id|>",
88
+ "",
89
+ `Sujet : "${topic.title}"`,
90
+ ];
91
+
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|>",
99
+ "",
100
+ `<|im_pseudo|>${post.user}<|end_pseudo|>`,
101
+ `<|im_date|>Le ${iso8601ToFrench(post.date)}<|end_date|>`,
102
+ "",
103
+ `<|begin_of_post|>${post.content}<|end_of_post|>`
104
+ ];
105
+
106
+ return lines.join("\n");
107
+ }
src/utils/oobabooga.ts ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = {
13
+ id: string; // Unique identifier for the chunk
14
+ object: string; // The type of the chunk, e.g., "text_completion.chunk"
15
+ created: number; // Unix timestamp of when the chunk was created
16
+ model: string; // Name or identifier of the model generating the completion
17
+ choices: {
18
+ index: number; // The index of the choice in the completion
19
+ finish_reason: string | null; // Reason why the completion stopped, or null if still in progress
20
+ text: string; // The generated text for this chunk
21
+ logprobs: {
22
+ top_logprobs: Record<string, number>[]; // Log probabilities for the top tokens, as an array of key-value pairs
23
+ };
24
+ }[];
25
+ usage?: {
26
+ prompt_tokens: number; // Number of tokens in the prompt
27
+ completion_tokens: number; // Number of tokens generated in the completion
28
+ total_tokens: number; // Total tokens used
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";
48
+
49
+ console.log("rawOutput");
50
+ console.log(rawOutput);
51
+
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",
96
+ headers: {
97
+ "Content-Type": "application/json",
98
+ },
99
+ body: JSON.stringify({
100
+ prompt,
101
+ temperature: settings.temperature,
102
+ max_tokens: 2000,
103
+ stream: true,
104
+ skip_special_tokens: false,
105
+ // stop: "<|end_of_post|>"
106
+ // top_p: 1,
107
+ // frequency_penalty: 0,
108
+ // presence_penalty: 0,
109
+ }),
110
+ signal: controller.signal,
111
+ });
112
+
113
+ if (!response.ok) {
114
+ throw new Error(`Failed to fetch API (${response.status} ${response.statusText}): ${await response.text()}`);
115
+ }
116
+
117
+ // console.log("Streaming !!!!");
118
+ //
119
+ // const decoderStream = new TextDecoderStream("utf-8");
120
+ // const writer = new WritableStream({
121
+ // write(rawChunk: string) {
122
+ // // output.innerHTML += chunk;
123
+ // const chunk = JSON.parse(rawChunk.trimStart().slice(6)) as OobaboogaStreamChunk; // remove "data: " and parse
124
+ // console.log(chunk)
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
132
+ let finishReason: string | null = null;
133
+
134
+ try {
135
+ await response.body.pipeThrough(new TextDecoderStream("utf-8")).pipeTo(new WritableStream({
136
+ write(rawChunk: string) {
137
+ // chunk can contains multiple lines, one chunk data per line
138
+ for (const rawChunkLine of rawChunk.split("\n")) {
139
+ if (!rawChunkLine.startsWith("data:")) continue;
140
+ const chunk = JSON.parse(rawChunkLine.slice(6)) as OobaboogaStreamChunk; // remove "data: " and parse
141
+ const text = chunk.choices[0].text;
142
+ console.log(text)
143
+ tokens += chunk.choices[0].text;
144
+ if (text.includes(postEndToken)) {
145
+ endTokenCount++;
146
+
147
+ if(endTokenCount >= nPosts) {
148
+ finishReason = "custom_stop";
149
+ controller.abort();
150
+ break;
151
+ }
152
+ } else {
153
+ finishReason = chunk.choices[0].finish_reason;
154
+ }
155
+ }
156
+ // output.innerHTML += chunk;
157
+ // console.log("----")
158
+ // console.log(rawChunk)
159
+ // console.log(rawChunk.slice(6).trimEnd())
160
+
161
+ // console.log(chunk.choices[0].text)
162
+ // tokens += chunk.choices[0].text;
163
+ }
164
+ }));
165
+ } catch (e) {
166
+ if (e.name !== 'AbortError') {
167
+ throw e;
168
+ }
169
+ }
170
+
171
+ console.log("Done fetching data")
172
+ console.log(`Finish reason: ${finishReason}`)
173
+ console.log(`Tokens: ${tokens}`)
174
+
175
+ return tokens;
176
+ }
src/utils/route.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:8000",
11
+ temperature: 0.75,
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
+ }
src/utils/smileys.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const smileysMap: [string, string][] = [
2
+ [":)", "1"], // https://image.jeuxvideo.com/smileys_img/1.gif
3
+ [":snif:", "20"],
4
+ [":gba:", "17"],
5
+ [":g)", "3"],
6
+ [":-)", "46"],
7
+ [":snif2:", "13"],
8
+ [":bravo:", "69"],
9
+ [":d)", "4"],
10
+ [":hap:", "18"],
11
+ [":ouch:", "22"],
12
+ [":pacg:", "9"],
13
+ [":cd:", "5"],
14
+ [":-)))", "23"],
15
+ [":ouch2:", "57"],
16
+ [":pacd:", "10"],
17
+ [":cute:", "nyu"],
18
+ [":content:", "24"],
19
+ [":p)", "7"],
20
+ [":-p", "31"],
21
+ [":noel:", "11"],
22
+ [":oui:", "37"],
23
+ [":(", "45"],
24
+ [":peur:", "47"],
25
+ [":question:", "2"],
26
+ [":cool:", "26"],
27
+ [":-(", "14"],
28
+ [":coeur:", "54"],
29
+ [":mort:", "21"],
30
+ [":rire:", "39"],
31
+ [":-((", "15"],
32
+ [":fou:", "50"],
33
+ [":sleep:", "27"],
34
+ [":-D", "40"],
35
+ [":nonnon:", "25"],
36
+ [":fier:", "53"],
37
+ [":honte:", "30"],
38
+ [":rire2:", "41"],
39
+ [":non2:", "33"],
40
+ [":sarcastic:", "43"],
41
+ [":monoeil:", "34"],
42
+ [":o))", "12"],
43
+ [":nah:", "19"],
44
+ [":doute:", "28"],
45
+ [":rouge:", "55"],
46
+ [":ok:", "36"],
47
+ [":non:", "35"],
48
+ [":malade:", "8"],
49
+ [":fete:", "66"],
50
+ [":sournois:", "67"],
51
+ [":hum:", "68"],
52
+ [":ange:", "60"],
53
+ [":diable:", "61"],
54
+ [":gni:", "62"],
55
+ [":play:", "play"],
56
+ [":desole:", "65"],
57
+ [":spoiler:", "63"],
58
+ [":merci:", "58"],
59
+ [":svp:", "59"],
60
+ [":sors:", "56"],
61
+ [":salut:", "42"],
62
+ [":rechercher:", "38"],
63
+ [":hello:", "29"],
64
+ [":up:", "44"],
65
+ [":bye:", "48"],
66
+ [":gne:", "51"],
67
+ [":lol:", "32"],
68
+ [":dpdr:", "49"],
69
+ [":dehors:", "52"],
70
+ [":hs:", "64"],
71
+ [":banzai:", "70"],
72
+ [":bave:", "71"],
73
+ [":pf:", "pf"],
74
+ [":cimer:", "cimer"],
75
+ [":ddb:", "ddb"],
76
+ [":pave:", "pave"],
77
+ [":objection:", "objection"],
78
+ [":siffle:", "siffle"],
79
+ ];
src/utils/topics.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ];
src/utils/uuids.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export function generateUUID(): string {
2
+ return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
3
+ (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
4
+ );
5
+ }
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
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
+ },
20
+ "include": ["node_modules/vite/client.d.ts", "**/*"]
21
+ }
vite.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {defineConfig} from 'vite';
2
+ // import {patchCssModules} from "vite-css-modules"
3
+ import preact from '@preact/preset-vite';
4
+
5
+ // https://vitejs.dev/config/
6
+ export default defineConfig({
7
+ plugins: [
8
+ // patchCssModules(),
9
+ preact({ // I had to disable HMR due to un Vite bug (@see https://github.com/privatenumber/vite-css-modules)
10
+ devToolsEnabled: false,
11
+ reactAliasesEnabled: false,
12
+ prefreshEnabled: false
13
+ })
14
+ ],
15
+ build: {
16
+ target: "es2022"
17
+ },
18
+ });