├── .gitignore
├── .node-version
├── .prettierignore
├── LICENSE.md
├── README.md
├── bsconfig.json
├── index.html
├── logo.png
├── package.json
├── pnpm-lock.yaml
├── public
└── robots.txt
├── src
├── App.res
├── App.resi
├── component
│ ├── ErrorDetails.res
│ ├── Link.res
│ ├── Pagination.res
│ ├── Route.res
│ ├── Security.res
│ ├── Spinner.res
│ └── WithTestId.res
├── favicon.ico
├── main.res
├── page
│ ├── Article
│ │ ├── Article.res
│ │ ├── ArticleAuthorAvatar.res
│ │ ├── ArticleAuthorName.res
│ │ ├── ArticleComments.res
│ │ ├── ArticleDate.res
│ │ ├── ArticleDeleteButton.res
│ │ ├── ArticleEditButton.res
│ │ ├── ArticleFavoriteButton.res
│ │ ├── ArticleFollowButton.res
│ │ ├── ArticlePostComment.res
│ │ └── ArticleTagList.res
│ ├── Editor.res
│ ├── Footer.res
│ ├── Header.res
│ ├── Home.res
│ ├── HomeArticlePreview.res
│ ├── HomePopularTags.res
│ ├── Login.res
│ ├── Profile.res
│ ├── Register.res
│ └── Settings.res
└── shared
│ ├── API.res
│ ├── AppError.res
│ ├── AsyncData.res
│ ├── AsyncResult.res
│ ├── Constant.res
│ ├── Endpoints.res
│ ├── Hook.res
│ ├── Markdown.res
│ ├── Markdown.resi
│ ├── Shape.res
│ └── Utils.res
└── vite.config.mjs
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 |
7 | # ReScript
8 | .bsb.lock
9 | .merlin
10 | /lib/
11 | *.bs.js
12 |
13 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/*
2 | lib/*
3 | node_modules
4 | package.json
5 | package-lock.json
6 | bsconfig.json
7 | *.bs.js
8 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jihchi Lee
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | 
4 | 
5 | 
6 |
7 | > ### ReScript + React codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
8 |
9 | ### [Demo](https://rescript-react-realworld-example-app.vercel.app) [RealWorld](https://github.com/gothinkster/realworld)
10 |
11 | This codebase was created to demonstrate a fully fledged fullstack application built with **[ReScript & React](https://rescript-lang.org/docs/react/latest/introduction)** including CRUD operations, authentication, routing, pagination, and more.
12 |
13 | We've gone to great lengths to adhere to the **ReScript & React** community styleguides & best practices.
14 |
15 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
16 |
17 | # How it works
18 |
19 | Basically its just like React single-page-application but written in [ReScript](https://rescript-lang.org/) with [React](https://reactjs.org/).
20 |
21 | - Using [Vite](https://vitejs.dev/) as the frontend build tool
22 | - Seamlessly integrate with [ReScript](https://rescript-lang.org/) (previously known as BuckleScript/ReasonML) and [rescript-react](https://rescript-lang.org/docs/react/latest/introduction)
23 | - Routing - ReScript React [Router](https://rescript-lang.org/docs/react/latest/router)
24 |
25 | # Getting started
26 |
27 | You can view a live demo over at https://rescript-react-realworld-example-app.vercel.app
28 |
29 | To get the frontend running locally:
30 |
31 | ```bash
32 | git clone https://github.com/jihchi/rescript-react-realworld-example-app.git
33 | cd rescript-react-realworld-example-app
34 | pnpm install
35 | pnpm start
36 | ```
37 |
38 | Then open http://localhost:5173 to see your app.
39 |
40 | When you’re ready to deploy to production, create a production build with `pnpm run build` and you will find result in folder `/dist`, after you created a production build, you can execute `pnpm run serve` to serve the folder.
41 |
42 | ## Contributors
43 |
44 | Many thanks for your help!
45 |
46 |
47 |
48 |
49 |
50 | The image of contributors is made with [contrib.rocks](https://contrib.rocks).
51 |
--------------------------------------------------------------------------------
/bsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rescript-react-realworld-example-app",
3 | "namespace": true,
4 | "suffix": ".bs.js",
5 | "jsx": {
6 | "version": 4,
7 | "mode": "automatic"
8 | },
9 | "bsc-flags": [
10 | "-bs-super-errors",
11 | "-bs-no-version-header",
12 | "-open RescriptCore"
13 | ],
14 | "sources": {
15 | "dir": "src",
16 | "subdirs": true
17 | },
18 | "package-specs": [
19 | {
20 | "module": "es6",
21 | "in-source": true
22 | }
23 | ],
24 | "bs-dependencies": [
25 | "@glennsl/rescript-fetch",
26 | "@rescript/core",
27 | "@rescript/react",
28 | "rescript-webapi"
29 | ],
30 | "bs-dev-dependencies": []
31 | }
32 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Conduit
10 |
11 |
17 |
23 |
24 |
25 |
30 |
67 |
68 |
69 | You need to enable JavaScript to run this app.
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihchi/rescript-react-realworld-example-app/39e136f7666b0b3871c9c1e4bcd1839cd879b41b/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rescript-react-realworld-example-app",
3 | "version": "1.0.0",
4 | "keywords": [
5 | "ReasonML",
6 | "BuckleScript",
7 | "reason-react",
8 | "ReScript",
9 | "rescript-react",
10 | "react"
11 | ],
12 | "license": "MIT",
13 | "author": "Jihchi Lee ",
14 | "scripts": {
15 | "build": "vite build",
16 | "clean": "rescript clean",
17 | "format": "npm run format:js && npm run format:res",
18 | "format:js": "prettier --write \"**/*.{js,json,md,yml}\"",
19 | "format:res": "rescript format -all",
20 | "serve": "vite preview",
21 | "start": "vite",
22 | "test": "echo \"Error: no test specified\" && exit 1"
23 | },
24 | "browserslist": {
25 | "production": [
26 | ">0.2%",
27 | "not dead",
28 | "not op_mini all"
29 | ],
30 | "development": [
31 | "last 1 chrome version",
32 | "last 1 firefox version",
33 | "last 1 safari version"
34 | ]
35 | },
36 | "prettier": {
37 | "singleQuote": true,
38 | "trailingComma": "es5"
39 | },
40 | "dependencies": {
41 | "@glennsl/rescript-fetch": "^0.2.0",
42 | "@rescript/core": "^0.5.0",
43 | "@rescript/react": "^0.11.0",
44 | "dompurify": "^3.0.5",
45 | "marked": "^9.0.3",
46 | "react": "^18.2.0",
47 | "react-dom": "^18.2.0",
48 | "rescript-webapi": "^0.9.0"
49 | },
50 | "devDependencies": {
51 | "@jihchi/vite-plugin-rescript": "^7.0.0",
52 | "@vitejs/plugin-react": "^4.3.4",
53 | "prettier": "^3.0.3",
54 | "rescript": "11.0.0-alpha.6",
55 | "vite": "^6.2.2"
56 | },
57 | "packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af",
58 | "engines": {
59 | "node": "^18"
60 | },
61 | "pnpm": {
62 | "onlyBuiltDependencies": [
63 | "esbuild",
64 | "rescript"
65 | ]
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | '@glennsl/rescript-fetch':
12 | specifier: ^0.2.0
13 | version: 0.2.3
14 | '@rescript/core':
15 | specifier: ^0.5.0
16 | version: 0.5.0(rescript@11.0.0-alpha.6)
17 | '@rescript/react':
18 | specifier: ^0.11.0
19 | version: 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
20 | dompurify:
21 | specifier: ^3.0.5
22 | version: 3.2.4
23 | marked:
24 | specifier: ^9.0.3
25 | version: 9.1.6
26 | react:
27 | specifier: ^18.2.0
28 | version: 18.3.1
29 | react-dom:
30 | specifier: ^18.2.0
31 | version: 18.3.1(react@18.3.1)
32 | rescript-webapi:
33 | specifier: ^0.9.0
34 | version: 0.9.1
35 | devDependencies:
36 | '@jihchi/vite-plugin-rescript':
37 | specifier: ^7.0.0
38 | version: 7.0.0(rescript@11.0.0-alpha.6)(vite@6.2.2)
39 | '@vitejs/plugin-react':
40 | specifier: ^4.3.4
41 | version: 4.3.4(vite@6.2.2)
42 | prettier:
43 | specifier: ^3.0.3
44 | version: 3.5.3
45 | rescript:
46 | specifier: 11.0.0-alpha.6
47 | version: 11.0.0-alpha.6
48 | vite:
49 | specifier: ^6.2.2
50 | version: 6.2.2
51 |
52 | packages:
53 |
54 | '@ampproject/remapping@2.3.0':
55 | resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
56 | engines: {node: '>=6.0.0'}
57 |
58 | '@babel/code-frame@7.26.2':
59 | resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
60 | engines: {node: '>=6.9.0'}
61 |
62 | '@babel/compat-data@7.26.8':
63 | resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==}
64 | engines: {node: '>=6.9.0'}
65 |
66 | '@babel/core@7.26.10':
67 | resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==}
68 | engines: {node: '>=6.9.0'}
69 |
70 | '@babel/generator@7.26.10':
71 | resolution: {integrity: sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==}
72 | engines: {node: '>=6.9.0'}
73 |
74 | '@babel/helper-compilation-targets@7.26.5':
75 | resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==}
76 | engines: {node: '>=6.9.0'}
77 |
78 | '@babel/helper-module-imports@7.25.9':
79 | resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
80 | engines: {node: '>=6.9.0'}
81 |
82 | '@babel/helper-module-transforms@7.26.0':
83 | resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==}
84 | engines: {node: '>=6.9.0'}
85 | peerDependencies:
86 | '@babel/core': ^7.0.0
87 |
88 | '@babel/helper-plugin-utils@7.26.5':
89 | resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==}
90 | engines: {node: '>=6.9.0'}
91 |
92 | '@babel/helper-string-parser@7.25.9':
93 | resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
94 | engines: {node: '>=6.9.0'}
95 |
96 | '@babel/helper-validator-identifier@7.25.9':
97 | resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
98 | engines: {node: '>=6.9.0'}
99 |
100 | '@babel/helper-validator-option@7.25.9':
101 | resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
102 | engines: {node: '>=6.9.0'}
103 |
104 | '@babel/helpers@7.26.10':
105 | resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==}
106 | engines: {node: '>=6.9.0'}
107 |
108 | '@babel/parser@7.26.10':
109 | resolution: {integrity: sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==}
110 | engines: {node: '>=6.0.0'}
111 | hasBin: true
112 |
113 | '@babel/plugin-transform-react-jsx-self@7.25.9':
114 | resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==}
115 | engines: {node: '>=6.9.0'}
116 | peerDependencies:
117 | '@babel/core': ^7.0.0-0
118 |
119 | '@babel/plugin-transform-react-jsx-source@7.25.9':
120 | resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==}
121 | engines: {node: '>=6.9.0'}
122 | peerDependencies:
123 | '@babel/core': ^7.0.0-0
124 |
125 | '@babel/template@7.26.9':
126 | resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==}
127 | engines: {node: '>=6.9.0'}
128 |
129 | '@babel/traverse@7.26.10':
130 | resolution: {integrity: sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==}
131 | engines: {node: '>=6.9.0'}
132 |
133 | '@babel/types@7.26.10':
134 | resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==}
135 | engines: {node: '>=6.9.0'}
136 |
137 | '@esbuild/aix-ppc64@0.25.1':
138 | resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==}
139 | engines: {node: '>=18'}
140 | cpu: [ppc64]
141 | os: [aix]
142 |
143 | '@esbuild/android-arm64@0.25.1':
144 | resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==}
145 | engines: {node: '>=18'}
146 | cpu: [arm64]
147 | os: [android]
148 |
149 | '@esbuild/android-arm@0.25.1':
150 | resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==}
151 | engines: {node: '>=18'}
152 | cpu: [arm]
153 | os: [android]
154 |
155 | '@esbuild/android-x64@0.25.1':
156 | resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==}
157 | engines: {node: '>=18'}
158 | cpu: [x64]
159 | os: [android]
160 |
161 | '@esbuild/darwin-arm64@0.25.1':
162 | resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==}
163 | engines: {node: '>=18'}
164 | cpu: [arm64]
165 | os: [darwin]
166 |
167 | '@esbuild/darwin-x64@0.25.1':
168 | resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==}
169 | engines: {node: '>=18'}
170 | cpu: [x64]
171 | os: [darwin]
172 |
173 | '@esbuild/freebsd-arm64@0.25.1':
174 | resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==}
175 | engines: {node: '>=18'}
176 | cpu: [arm64]
177 | os: [freebsd]
178 |
179 | '@esbuild/freebsd-x64@0.25.1':
180 | resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==}
181 | engines: {node: '>=18'}
182 | cpu: [x64]
183 | os: [freebsd]
184 |
185 | '@esbuild/linux-arm64@0.25.1':
186 | resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==}
187 | engines: {node: '>=18'}
188 | cpu: [arm64]
189 | os: [linux]
190 |
191 | '@esbuild/linux-arm@0.25.1':
192 | resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==}
193 | engines: {node: '>=18'}
194 | cpu: [arm]
195 | os: [linux]
196 |
197 | '@esbuild/linux-ia32@0.25.1':
198 | resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==}
199 | engines: {node: '>=18'}
200 | cpu: [ia32]
201 | os: [linux]
202 |
203 | '@esbuild/linux-loong64@0.25.1':
204 | resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==}
205 | engines: {node: '>=18'}
206 | cpu: [loong64]
207 | os: [linux]
208 |
209 | '@esbuild/linux-mips64el@0.25.1':
210 | resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==}
211 | engines: {node: '>=18'}
212 | cpu: [mips64el]
213 | os: [linux]
214 |
215 | '@esbuild/linux-ppc64@0.25.1':
216 | resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==}
217 | engines: {node: '>=18'}
218 | cpu: [ppc64]
219 | os: [linux]
220 |
221 | '@esbuild/linux-riscv64@0.25.1':
222 | resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==}
223 | engines: {node: '>=18'}
224 | cpu: [riscv64]
225 | os: [linux]
226 |
227 | '@esbuild/linux-s390x@0.25.1':
228 | resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==}
229 | engines: {node: '>=18'}
230 | cpu: [s390x]
231 | os: [linux]
232 |
233 | '@esbuild/linux-x64@0.25.1':
234 | resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==}
235 | engines: {node: '>=18'}
236 | cpu: [x64]
237 | os: [linux]
238 |
239 | '@esbuild/netbsd-arm64@0.25.1':
240 | resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==}
241 | engines: {node: '>=18'}
242 | cpu: [arm64]
243 | os: [netbsd]
244 |
245 | '@esbuild/netbsd-x64@0.25.1':
246 | resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==}
247 | engines: {node: '>=18'}
248 | cpu: [x64]
249 | os: [netbsd]
250 |
251 | '@esbuild/openbsd-arm64@0.25.1':
252 | resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==}
253 | engines: {node: '>=18'}
254 | cpu: [arm64]
255 | os: [openbsd]
256 |
257 | '@esbuild/openbsd-x64@0.25.1':
258 | resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==}
259 | engines: {node: '>=18'}
260 | cpu: [x64]
261 | os: [openbsd]
262 |
263 | '@esbuild/sunos-x64@0.25.1':
264 | resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==}
265 | engines: {node: '>=18'}
266 | cpu: [x64]
267 | os: [sunos]
268 |
269 | '@esbuild/win32-arm64@0.25.1':
270 | resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==}
271 | engines: {node: '>=18'}
272 | cpu: [arm64]
273 | os: [win32]
274 |
275 | '@esbuild/win32-ia32@0.25.1':
276 | resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==}
277 | engines: {node: '>=18'}
278 | cpu: [ia32]
279 | os: [win32]
280 |
281 | '@esbuild/win32-x64@0.25.1':
282 | resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==}
283 | engines: {node: '>=18'}
284 | cpu: [x64]
285 | os: [win32]
286 |
287 | '@glennsl/rescript-fetch@0.2.3':
288 | resolution: {integrity: sha512-0MuaXeJHGii0/SKzR4ASB1Wal+ogdnNsD1K6HSTFUVc+TQzAZY+V8Nb7Z8K4OUqMJzmurIgVXNmirx3DC1Huyg==}
289 |
290 | '@jihchi/vite-plugin-rescript@7.0.0':
291 | resolution: {integrity: sha512-BwfFY1hAKE3OP6Ni1wGm9KYors9itbcgkiCe++Ll3XEM9UyrGzVeoIkLOFQJONXkdwRpbDW1HpQnGRbJDgTKJA==}
292 | engines: {node: '>=18.0'}
293 | peerDependencies:
294 | rescript: '>=9'
295 | vite: '>=5.1.0'
296 |
297 | '@jridgewell/gen-mapping@0.3.8':
298 | resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
299 | engines: {node: '>=6.0.0'}
300 |
301 | '@jridgewell/resolve-uri@3.1.2':
302 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
303 | engines: {node: '>=6.0.0'}
304 |
305 | '@jridgewell/set-array@1.2.1':
306 | resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
307 | engines: {node: '>=6.0.0'}
308 |
309 | '@jridgewell/sourcemap-codec@1.5.0':
310 | resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
311 |
312 | '@jridgewell/trace-mapping@0.3.25':
313 | resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
314 |
315 | '@rescript/core@0.5.0':
316 | resolution: {integrity: sha512-Keqnpi+8VqyhCk/3aMwar8hJbNy2IsINAAfIFeQC65IIegCR0QXFDBpQxfVcmbbtoHq6HnW4B3RLm/9GCUJQhQ==}
317 | peerDependencies:
318 | rescript: ^10.1.0 || ^11.0.0-alpha.0 || next
319 |
320 | '@rescript/react@0.11.0':
321 | resolution: {integrity: sha512-RzoAO+3cJwXE2D7yodMo4tBO2EkeDYCN/I/Sj/yRweI3S1CY1ZBOF/GMcVtjeIurJJt7KMveqQXTaRrqoGZBBg==}
322 | peerDependencies:
323 | react: '>=18.0.0'
324 | react-dom: '>=18.0.0'
325 |
326 | '@rollup/rollup-android-arm-eabi@4.36.0':
327 | resolution: {integrity: sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==}
328 | cpu: [arm]
329 | os: [android]
330 |
331 | '@rollup/rollup-android-arm64@4.36.0':
332 | resolution: {integrity: sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg==}
333 | cpu: [arm64]
334 | os: [android]
335 |
336 | '@rollup/rollup-darwin-arm64@4.36.0':
337 | resolution: {integrity: sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw==}
338 | cpu: [arm64]
339 | os: [darwin]
340 |
341 | '@rollup/rollup-darwin-x64@4.36.0':
342 | resolution: {integrity: sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA==}
343 | cpu: [x64]
344 | os: [darwin]
345 |
346 | '@rollup/rollup-freebsd-arm64@4.36.0':
347 | resolution: {integrity: sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg==}
348 | cpu: [arm64]
349 | os: [freebsd]
350 |
351 | '@rollup/rollup-freebsd-x64@4.36.0':
352 | resolution: {integrity: sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ==}
353 | cpu: [x64]
354 | os: [freebsd]
355 |
356 | '@rollup/rollup-linux-arm-gnueabihf@4.36.0':
357 | resolution: {integrity: sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==}
358 | cpu: [arm]
359 | os: [linux]
360 |
361 | '@rollup/rollup-linux-arm-musleabihf@4.36.0':
362 | resolution: {integrity: sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==}
363 | cpu: [arm]
364 | os: [linux]
365 |
366 | '@rollup/rollup-linux-arm64-gnu@4.36.0':
367 | resolution: {integrity: sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==}
368 | cpu: [arm64]
369 | os: [linux]
370 |
371 | '@rollup/rollup-linux-arm64-musl@4.36.0':
372 | resolution: {integrity: sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==}
373 | cpu: [arm64]
374 | os: [linux]
375 |
376 | '@rollup/rollup-linux-loongarch64-gnu@4.36.0':
377 | resolution: {integrity: sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==}
378 | cpu: [loong64]
379 | os: [linux]
380 |
381 | '@rollup/rollup-linux-powerpc64le-gnu@4.36.0':
382 | resolution: {integrity: sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==}
383 | cpu: [ppc64]
384 | os: [linux]
385 |
386 | '@rollup/rollup-linux-riscv64-gnu@4.36.0':
387 | resolution: {integrity: sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==}
388 | cpu: [riscv64]
389 | os: [linux]
390 |
391 | '@rollup/rollup-linux-s390x-gnu@4.36.0':
392 | resolution: {integrity: sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==}
393 | cpu: [s390x]
394 | os: [linux]
395 |
396 | '@rollup/rollup-linux-x64-gnu@4.36.0':
397 | resolution: {integrity: sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==}
398 | cpu: [x64]
399 | os: [linux]
400 |
401 | '@rollup/rollup-linux-x64-musl@4.36.0':
402 | resolution: {integrity: sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==}
403 | cpu: [x64]
404 | os: [linux]
405 |
406 | '@rollup/rollup-win32-arm64-msvc@4.36.0':
407 | resolution: {integrity: sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==}
408 | cpu: [arm64]
409 | os: [win32]
410 |
411 | '@rollup/rollup-win32-ia32-msvc@4.36.0':
412 | resolution: {integrity: sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ==}
413 | cpu: [ia32]
414 | os: [win32]
415 |
416 | '@rollup/rollup-win32-x64-msvc@4.36.0':
417 | resolution: {integrity: sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw==}
418 | cpu: [x64]
419 | os: [win32]
420 |
421 | '@sec-ant/readable-stream@0.4.1':
422 | resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
423 |
424 | '@sindresorhus/merge-streams@4.0.0':
425 | resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
426 | engines: {node: '>=18'}
427 |
428 | '@types/babel__core@7.20.5':
429 | resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
430 |
431 | '@types/babel__generator@7.6.8':
432 | resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==}
433 |
434 | '@types/babel__template@7.4.4':
435 | resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
436 |
437 | '@types/babel__traverse@7.20.6':
438 | resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
439 |
440 | '@types/estree@1.0.6':
441 | resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
442 |
443 | '@types/trusted-types@2.0.7':
444 | resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
445 |
446 | '@vitejs/plugin-react@4.3.4':
447 | resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==}
448 | engines: {node: ^14.18.0 || >=16.0.0}
449 | peerDependencies:
450 | vite: ^4.2.0 || ^5.0.0 || ^6.0.0
451 |
452 | browserslist@4.24.4:
453 | resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==}
454 | engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
455 | hasBin: true
456 |
457 | caniuse-lite@1.0.30001706:
458 | resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==}
459 |
460 | chalk@5.4.1:
461 | resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
462 | engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
463 |
464 | convert-source-map@2.0.0:
465 | resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
466 |
467 | cross-spawn@7.0.6:
468 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
469 | engines: {node: '>= 8'}
470 |
471 | debug@4.4.0:
472 | resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
473 | engines: {node: '>=6.0'}
474 | peerDependencies:
475 | supports-color: '*'
476 | peerDependenciesMeta:
477 | supports-color:
478 | optional: true
479 |
480 | dompurify@3.2.4:
481 | resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
482 |
483 | electron-to-chromium@1.5.123:
484 | resolution: {integrity: sha512-refir3NlutEZqlKaBLK0tzlVLe5P2wDKS7UQt/3SpibizgsRAPOsqQC3ffw1nlv3ze5gjRQZYHoPymgVZkplFA==}
485 |
486 | esbuild@0.25.1:
487 | resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==}
488 | engines: {node: '>=18'}
489 | hasBin: true
490 |
491 | escalade@3.2.0:
492 | resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
493 | engines: {node: '>=6'}
494 |
495 | execa@9.5.2:
496 | resolution: {integrity: sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==}
497 | engines: {node: ^18.19.0 || >=20.5.0}
498 |
499 | figures@6.1.0:
500 | resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
501 | engines: {node: '>=18'}
502 |
503 | fsevents@2.3.3:
504 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
505 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
506 | os: [darwin]
507 |
508 | gensync@1.0.0-beta.2:
509 | resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
510 | engines: {node: '>=6.9.0'}
511 |
512 | get-stream@9.0.1:
513 | resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
514 | engines: {node: '>=18'}
515 |
516 | globals@11.12.0:
517 | resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
518 | engines: {node: '>=4'}
519 |
520 | human-signals@8.0.0:
521 | resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==}
522 | engines: {node: '>=18.18.0'}
523 |
524 | is-plain-obj@4.1.0:
525 | resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
526 | engines: {node: '>=12'}
527 |
528 | is-stream@4.0.1:
529 | resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
530 | engines: {node: '>=18'}
531 |
532 | is-unicode-supported@2.1.0:
533 | resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
534 | engines: {node: '>=18'}
535 |
536 | isexe@2.0.0:
537 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
538 |
539 | js-tokens@4.0.0:
540 | resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
541 |
542 | jsesc@3.1.0:
543 | resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
544 | engines: {node: '>=6'}
545 | hasBin: true
546 |
547 | json5@2.2.3:
548 | resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
549 | engines: {node: '>=6'}
550 | hasBin: true
551 |
552 | loose-envify@1.4.0:
553 | resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
554 | hasBin: true
555 |
556 | lru-cache@5.1.1:
557 | resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
558 |
559 | marked@9.1.6:
560 | resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==}
561 | engines: {node: '>= 16'}
562 | hasBin: true
563 |
564 | ms@2.1.3:
565 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
566 |
567 | nanoid@3.3.11:
568 | resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
569 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
570 | hasBin: true
571 |
572 | node-releases@2.0.19:
573 | resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
574 |
575 | npm-run-path@6.0.0:
576 | resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
577 | engines: {node: '>=18'}
578 |
579 | parse-ms@4.0.0:
580 | resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
581 | engines: {node: '>=18'}
582 |
583 | path-key@3.1.1:
584 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
585 | engines: {node: '>=8'}
586 |
587 | path-key@4.0.0:
588 | resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
589 | engines: {node: '>=12'}
590 |
591 | picocolors@1.1.1:
592 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
593 |
594 | postcss@8.5.3:
595 | resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
596 | engines: {node: ^10 || ^12 || >=14}
597 |
598 | prettier@3.5.3:
599 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
600 | engines: {node: '>=14'}
601 | hasBin: true
602 |
603 | pretty-ms@9.2.0:
604 | resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==}
605 | engines: {node: '>=18'}
606 |
607 | react-dom@18.3.1:
608 | resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
609 | peerDependencies:
610 | react: ^18.3.1
611 |
612 | react-refresh@0.14.2:
613 | resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
614 | engines: {node: '>=0.10.0'}
615 |
616 | react@18.3.1:
617 | resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
618 | engines: {node: '>=0.10.0'}
619 |
620 | rescript-webapi@0.9.1:
621 | resolution: {integrity: sha512-6havxEsyCgcCB4EsH8ac2QpLVbv5Nv6IMjTIn6XifpU/bjgXEvMTpNJ78/JEKtSH6kFUeFV/T/QHPRSZb66Rew==}
622 |
623 | rescript@11.0.0-alpha.6:
624 | resolution: {integrity: sha512-516p5A+ybndzdxjKbOVJNrSd+p+U3FZ5Lm9U7SfGREzJXKACY3e69dk9ey/wDO4fh6Ge8j+NIXhHOTjFhMx4sw==}
625 | engines: {node: '>=10'}
626 | hasBin: true
627 |
628 | rollup@4.36.0:
629 | resolution: {integrity: sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q==}
630 | engines: {node: '>=18.0.0', npm: '>=8.0.0'}
631 | hasBin: true
632 |
633 | scheduler@0.23.2:
634 | resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
635 |
636 | semver@6.3.1:
637 | resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
638 | hasBin: true
639 |
640 | shebang-command@2.0.0:
641 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
642 | engines: {node: '>=8'}
643 |
644 | shebang-regex@3.0.0:
645 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
646 | engines: {node: '>=8'}
647 |
648 | signal-exit@4.1.0:
649 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
650 | engines: {node: '>=14'}
651 |
652 | source-map-js@1.2.1:
653 | resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
654 | engines: {node: '>=0.10.0'}
655 |
656 | strip-final-newline@4.0.0:
657 | resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
658 | engines: {node: '>=18'}
659 |
660 | unicorn-magic@0.3.0:
661 | resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
662 | engines: {node: '>=18'}
663 |
664 | update-browserslist-db@1.1.3:
665 | resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
666 | hasBin: true
667 | peerDependencies:
668 | browserslist: '>= 4.21.0'
669 |
670 | vite@6.2.2:
671 | resolution: {integrity: sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ==}
672 | engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
673 | hasBin: true
674 | peerDependencies:
675 | '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
676 | jiti: '>=1.21.0'
677 | less: '*'
678 | lightningcss: ^1.21.0
679 | sass: '*'
680 | sass-embedded: '*'
681 | stylus: '*'
682 | sugarss: '*'
683 | terser: ^5.16.0
684 | tsx: ^4.8.1
685 | yaml: ^2.4.2
686 | peerDependenciesMeta:
687 | '@types/node':
688 | optional: true
689 | jiti:
690 | optional: true
691 | less:
692 | optional: true
693 | lightningcss:
694 | optional: true
695 | sass:
696 | optional: true
697 | sass-embedded:
698 | optional: true
699 | stylus:
700 | optional: true
701 | sugarss:
702 | optional: true
703 | terser:
704 | optional: true
705 | tsx:
706 | optional: true
707 | yaml:
708 | optional: true
709 |
710 | which@2.0.2:
711 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
712 | engines: {node: '>= 8'}
713 | hasBin: true
714 |
715 | yallist@3.1.1:
716 | resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
717 |
718 | yoctocolors@2.1.1:
719 | resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
720 | engines: {node: '>=18'}
721 |
722 | snapshots:
723 |
724 | '@ampproject/remapping@2.3.0':
725 | dependencies:
726 | '@jridgewell/gen-mapping': 0.3.8
727 | '@jridgewell/trace-mapping': 0.3.25
728 |
729 | '@babel/code-frame@7.26.2':
730 | dependencies:
731 | '@babel/helper-validator-identifier': 7.25.9
732 | js-tokens: 4.0.0
733 | picocolors: 1.1.1
734 |
735 | '@babel/compat-data@7.26.8': {}
736 |
737 | '@babel/core@7.26.10':
738 | dependencies:
739 | '@ampproject/remapping': 2.3.0
740 | '@babel/code-frame': 7.26.2
741 | '@babel/generator': 7.26.10
742 | '@babel/helper-compilation-targets': 7.26.5
743 | '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10)
744 | '@babel/helpers': 7.26.10
745 | '@babel/parser': 7.26.10
746 | '@babel/template': 7.26.9
747 | '@babel/traverse': 7.26.10
748 | '@babel/types': 7.26.10
749 | convert-source-map: 2.0.0
750 | debug: 4.4.0
751 | gensync: 1.0.0-beta.2
752 | json5: 2.2.3
753 | semver: 6.3.1
754 | transitivePeerDependencies:
755 | - supports-color
756 |
757 | '@babel/generator@7.26.10':
758 | dependencies:
759 | '@babel/parser': 7.26.10
760 | '@babel/types': 7.26.10
761 | '@jridgewell/gen-mapping': 0.3.8
762 | '@jridgewell/trace-mapping': 0.3.25
763 | jsesc: 3.1.0
764 |
765 | '@babel/helper-compilation-targets@7.26.5':
766 | dependencies:
767 | '@babel/compat-data': 7.26.8
768 | '@babel/helper-validator-option': 7.25.9
769 | browserslist: 4.24.4
770 | lru-cache: 5.1.1
771 | semver: 6.3.1
772 |
773 | '@babel/helper-module-imports@7.25.9':
774 | dependencies:
775 | '@babel/traverse': 7.26.10
776 | '@babel/types': 7.26.10
777 | transitivePeerDependencies:
778 | - supports-color
779 |
780 | '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)':
781 | dependencies:
782 | '@babel/core': 7.26.10
783 | '@babel/helper-module-imports': 7.25.9
784 | '@babel/helper-validator-identifier': 7.25.9
785 | '@babel/traverse': 7.26.10
786 | transitivePeerDependencies:
787 | - supports-color
788 |
789 | '@babel/helper-plugin-utils@7.26.5': {}
790 |
791 | '@babel/helper-string-parser@7.25.9': {}
792 |
793 | '@babel/helper-validator-identifier@7.25.9': {}
794 |
795 | '@babel/helper-validator-option@7.25.9': {}
796 |
797 | '@babel/helpers@7.26.10':
798 | dependencies:
799 | '@babel/template': 7.26.9
800 | '@babel/types': 7.26.10
801 |
802 | '@babel/parser@7.26.10':
803 | dependencies:
804 | '@babel/types': 7.26.10
805 |
806 | '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)':
807 | dependencies:
808 | '@babel/core': 7.26.10
809 | '@babel/helper-plugin-utils': 7.26.5
810 |
811 | '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)':
812 | dependencies:
813 | '@babel/core': 7.26.10
814 | '@babel/helper-plugin-utils': 7.26.5
815 |
816 | '@babel/template@7.26.9':
817 | dependencies:
818 | '@babel/code-frame': 7.26.2
819 | '@babel/parser': 7.26.10
820 | '@babel/types': 7.26.10
821 |
822 | '@babel/traverse@7.26.10':
823 | dependencies:
824 | '@babel/code-frame': 7.26.2
825 | '@babel/generator': 7.26.10
826 | '@babel/parser': 7.26.10
827 | '@babel/template': 7.26.9
828 | '@babel/types': 7.26.10
829 | debug: 4.4.0
830 | globals: 11.12.0
831 | transitivePeerDependencies:
832 | - supports-color
833 |
834 | '@babel/types@7.26.10':
835 | dependencies:
836 | '@babel/helper-string-parser': 7.25.9
837 | '@babel/helper-validator-identifier': 7.25.9
838 |
839 | '@esbuild/aix-ppc64@0.25.1':
840 | optional: true
841 |
842 | '@esbuild/android-arm64@0.25.1':
843 | optional: true
844 |
845 | '@esbuild/android-arm@0.25.1':
846 | optional: true
847 |
848 | '@esbuild/android-x64@0.25.1':
849 | optional: true
850 |
851 | '@esbuild/darwin-arm64@0.25.1':
852 | optional: true
853 |
854 | '@esbuild/darwin-x64@0.25.1':
855 | optional: true
856 |
857 | '@esbuild/freebsd-arm64@0.25.1':
858 | optional: true
859 |
860 | '@esbuild/freebsd-x64@0.25.1':
861 | optional: true
862 |
863 | '@esbuild/linux-arm64@0.25.1':
864 | optional: true
865 |
866 | '@esbuild/linux-arm@0.25.1':
867 | optional: true
868 |
869 | '@esbuild/linux-ia32@0.25.1':
870 | optional: true
871 |
872 | '@esbuild/linux-loong64@0.25.1':
873 | optional: true
874 |
875 | '@esbuild/linux-mips64el@0.25.1':
876 | optional: true
877 |
878 | '@esbuild/linux-ppc64@0.25.1':
879 | optional: true
880 |
881 | '@esbuild/linux-riscv64@0.25.1':
882 | optional: true
883 |
884 | '@esbuild/linux-s390x@0.25.1':
885 | optional: true
886 |
887 | '@esbuild/linux-x64@0.25.1':
888 | optional: true
889 |
890 | '@esbuild/netbsd-arm64@0.25.1':
891 | optional: true
892 |
893 | '@esbuild/netbsd-x64@0.25.1':
894 | optional: true
895 |
896 | '@esbuild/openbsd-arm64@0.25.1':
897 | optional: true
898 |
899 | '@esbuild/openbsd-x64@0.25.1':
900 | optional: true
901 |
902 | '@esbuild/sunos-x64@0.25.1':
903 | optional: true
904 |
905 | '@esbuild/win32-arm64@0.25.1':
906 | optional: true
907 |
908 | '@esbuild/win32-ia32@0.25.1':
909 | optional: true
910 |
911 | '@esbuild/win32-x64@0.25.1':
912 | optional: true
913 |
914 | '@glennsl/rescript-fetch@0.2.3': {}
915 |
916 | '@jihchi/vite-plugin-rescript@7.0.0(rescript@11.0.0-alpha.6)(vite@6.2.2)':
917 | dependencies:
918 | chalk: 5.4.1
919 | execa: 9.5.2
920 | npm-run-path: 6.0.0
921 | rescript: 11.0.0-alpha.6
922 | vite: 6.2.2
923 |
924 | '@jridgewell/gen-mapping@0.3.8':
925 | dependencies:
926 | '@jridgewell/set-array': 1.2.1
927 | '@jridgewell/sourcemap-codec': 1.5.0
928 | '@jridgewell/trace-mapping': 0.3.25
929 |
930 | '@jridgewell/resolve-uri@3.1.2': {}
931 |
932 | '@jridgewell/set-array@1.2.1': {}
933 |
934 | '@jridgewell/sourcemap-codec@1.5.0': {}
935 |
936 | '@jridgewell/trace-mapping@0.3.25':
937 | dependencies:
938 | '@jridgewell/resolve-uri': 3.1.2
939 | '@jridgewell/sourcemap-codec': 1.5.0
940 |
941 | '@rescript/core@0.5.0(rescript@11.0.0-alpha.6)':
942 | dependencies:
943 | rescript: 11.0.0-alpha.6
944 |
945 | '@rescript/react@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
946 | dependencies:
947 | react: 18.3.1
948 | react-dom: 18.3.1(react@18.3.1)
949 |
950 | '@rollup/rollup-android-arm-eabi@4.36.0':
951 | optional: true
952 |
953 | '@rollup/rollup-android-arm64@4.36.0':
954 | optional: true
955 |
956 | '@rollup/rollup-darwin-arm64@4.36.0':
957 | optional: true
958 |
959 | '@rollup/rollup-darwin-x64@4.36.0':
960 | optional: true
961 |
962 | '@rollup/rollup-freebsd-arm64@4.36.0':
963 | optional: true
964 |
965 | '@rollup/rollup-freebsd-x64@4.36.0':
966 | optional: true
967 |
968 | '@rollup/rollup-linux-arm-gnueabihf@4.36.0':
969 | optional: true
970 |
971 | '@rollup/rollup-linux-arm-musleabihf@4.36.0':
972 | optional: true
973 |
974 | '@rollup/rollup-linux-arm64-gnu@4.36.0':
975 | optional: true
976 |
977 | '@rollup/rollup-linux-arm64-musl@4.36.0':
978 | optional: true
979 |
980 | '@rollup/rollup-linux-loongarch64-gnu@4.36.0':
981 | optional: true
982 |
983 | '@rollup/rollup-linux-powerpc64le-gnu@4.36.0':
984 | optional: true
985 |
986 | '@rollup/rollup-linux-riscv64-gnu@4.36.0':
987 | optional: true
988 |
989 | '@rollup/rollup-linux-s390x-gnu@4.36.0':
990 | optional: true
991 |
992 | '@rollup/rollup-linux-x64-gnu@4.36.0':
993 | optional: true
994 |
995 | '@rollup/rollup-linux-x64-musl@4.36.0':
996 | optional: true
997 |
998 | '@rollup/rollup-win32-arm64-msvc@4.36.0':
999 | optional: true
1000 |
1001 | '@rollup/rollup-win32-ia32-msvc@4.36.0':
1002 | optional: true
1003 |
1004 | '@rollup/rollup-win32-x64-msvc@4.36.0':
1005 | optional: true
1006 |
1007 | '@sec-ant/readable-stream@0.4.1': {}
1008 |
1009 | '@sindresorhus/merge-streams@4.0.0': {}
1010 |
1011 | '@types/babel__core@7.20.5':
1012 | dependencies:
1013 | '@babel/parser': 7.26.10
1014 | '@babel/types': 7.26.10
1015 | '@types/babel__generator': 7.6.8
1016 | '@types/babel__template': 7.4.4
1017 | '@types/babel__traverse': 7.20.6
1018 |
1019 | '@types/babel__generator@7.6.8':
1020 | dependencies:
1021 | '@babel/types': 7.26.10
1022 |
1023 | '@types/babel__template@7.4.4':
1024 | dependencies:
1025 | '@babel/parser': 7.26.10
1026 | '@babel/types': 7.26.10
1027 |
1028 | '@types/babel__traverse@7.20.6':
1029 | dependencies:
1030 | '@babel/types': 7.26.10
1031 |
1032 | '@types/estree@1.0.6': {}
1033 |
1034 | '@types/trusted-types@2.0.7':
1035 | optional: true
1036 |
1037 | '@vitejs/plugin-react@4.3.4(vite@6.2.2)':
1038 | dependencies:
1039 | '@babel/core': 7.26.10
1040 | '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
1041 | '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
1042 | '@types/babel__core': 7.20.5
1043 | react-refresh: 0.14.2
1044 | vite: 6.2.2
1045 | transitivePeerDependencies:
1046 | - supports-color
1047 |
1048 | browserslist@4.24.4:
1049 | dependencies:
1050 | caniuse-lite: 1.0.30001706
1051 | electron-to-chromium: 1.5.123
1052 | node-releases: 2.0.19
1053 | update-browserslist-db: 1.1.3(browserslist@4.24.4)
1054 |
1055 | caniuse-lite@1.0.30001706: {}
1056 |
1057 | chalk@5.4.1: {}
1058 |
1059 | convert-source-map@2.0.0: {}
1060 |
1061 | cross-spawn@7.0.6:
1062 | dependencies:
1063 | path-key: 3.1.1
1064 | shebang-command: 2.0.0
1065 | which: 2.0.2
1066 |
1067 | debug@4.4.0:
1068 | dependencies:
1069 | ms: 2.1.3
1070 |
1071 | dompurify@3.2.4:
1072 | optionalDependencies:
1073 | '@types/trusted-types': 2.0.7
1074 |
1075 | electron-to-chromium@1.5.123: {}
1076 |
1077 | esbuild@0.25.1:
1078 | optionalDependencies:
1079 | '@esbuild/aix-ppc64': 0.25.1
1080 | '@esbuild/android-arm': 0.25.1
1081 | '@esbuild/android-arm64': 0.25.1
1082 | '@esbuild/android-x64': 0.25.1
1083 | '@esbuild/darwin-arm64': 0.25.1
1084 | '@esbuild/darwin-x64': 0.25.1
1085 | '@esbuild/freebsd-arm64': 0.25.1
1086 | '@esbuild/freebsd-x64': 0.25.1
1087 | '@esbuild/linux-arm': 0.25.1
1088 | '@esbuild/linux-arm64': 0.25.1
1089 | '@esbuild/linux-ia32': 0.25.1
1090 | '@esbuild/linux-loong64': 0.25.1
1091 | '@esbuild/linux-mips64el': 0.25.1
1092 | '@esbuild/linux-ppc64': 0.25.1
1093 | '@esbuild/linux-riscv64': 0.25.1
1094 | '@esbuild/linux-s390x': 0.25.1
1095 | '@esbuild/linux-x64': 0.25.1
1096 | '@esbuild/netbsd-arm64': 0.25.1
1097 | '@esbuild/netbsd-x64': 0.25.1
1098 | '@esbuild/openbsd-arm64': 0.25.1
1099 | '@esbuild/openbsd-x64': 0.25.1
1100 | '@esbuild/sunos-x64': 0.25.1
1101 | '@esbuild/win32-arm64': 0.25.1
1102 | '@esbuild/win32-ia32': 0.25.1
1103 | '@esbuild/win32-x64': 0.25.1
1104 |
1105 | escalade@3.2.0: {}
1106 |
1107 | execa@9.5.2:
1108 | dependencies:
1109 | '@sindresorhus/merge-streams': 4.0.0
1110 | cross-spawn: 7.0.6
1111 | figures: 6.1.0
1112 | get-stream: 9.0.1
1113 | human-signals: 8.0.0
1114 | is-plain-obj: 4.1.0
1115 | is-stream: 4.0.1
1116 | npm-run-path: 6.0.0
1117 | pretty-ms: 9.2.0
1118 | signal-exit: 4.1.0
1119 | strip-final-newline: 4.0.0
1120 | yoctocolors: 2.1.1
1121 |
1122 | figures@6.1.0:
1123 | dependencies:
1124 | is-unicode-supported: 2.1.0
1125 |
1126 | fsevents@2.3.3:
1127 | optional: true
1128 |
1129 | gensync@1.0.0-beta.2: {}
1130 |
1131 | get-stream@9.0.1:
1132 | dependencies:
1133 | '@sec-ant/readable-stream': 0.4.1
1134 | is-stream: 4.0.1
1135 |
1136 | globals@11.12.0: {}
1137 |
1138 | human-signals@8.0.0: {}
1139 |
1140 | is-plain-obj@4.1.0: {}
1141 |
1142 | is-stream@4.0.1: {}
1143 |
1144 | is-unicode-supported@2.1.0: {}
1145 |
1146 | isexe@2.0.0: {}
1147 |
1148 | js-tokens@4.0.0: {}
1149 |
1150 | jsesc@3.1.0: {}
1151 |
1152 | json5@2.2.3: {}
1153 |
1154 | loose-envify@1.4.0:
1155 | dependencies:
1156 | js-tokens: 4.0.0
1157 |
1158 | lru-cache@5.1.1:
1159 | dependencies:
1160 | yallist: 3.1.1
1161 |
1162 | marked@9.1.6: {}
1163 |
1164 | ms@2.1.3: {}
1165 |
1166 | nanoid@3.3.11: {}
1167 |
1168 | node-releases@2.0.19: {}
1169 |
1170 | npm-run-path@6.0.0:
1171 | dependencies:
1172 | path-key: 4.0.0
1173 | unicorn-magic: 0.3.0
1174 |
1175 | parse-ms@4.0.0: {}
1176 |
1177 | path-key@3.1.1: {}
1178 |
1179 | path-key@4.0.0: {}
1180 |
1181 | picocolors@1.1.1: {}
1182 |
1183 | postcss@8.5.3:
1184 | dependencies:
1185 | nanoid: 3.3.11
1186 | picocolors: 1.1.1
1187 | source-map-js: 1.2.1
1188 |
1189 | prettier@3.5.3: {}
1190 |
1191 | pretty-ms@9.2.0:
1192 | dependencies:
1193 | parse-ms: 4.0.0
1194 |
1195 | react-dom@18.3.1(react@18.3.1):
1196 | dependencies:
1197 | loose-envify: 1.4.0
1198 | react: 18.3.1
1199 | scheduler: 0.23.2
1200 |
1201 | react-refresh@0.14.2: {}
1202 |
1203 | react@18.3.1:
1204 | dependencies:
1205 | loose-envify: 1.4.0
1206 |
1207 | rescript-webapi@0.9.1: {}
1208 |
1209 | rescript@11.0.0-alpha.6: {}
1210 |
1211 | rollup@4.36.0:
1212 | dependencies:
1213 | '@types/estree': 1.0.6
1214 | optionalDependencies:
1215 | '@rollup/rollup-android-arm-eabi': 4.36.0
1216 | '@rollup/rollup-android-arm64': 4.36.0
1217 | '@rollup/rollup-darwin-arm64': 4.36.0
1218 | '@rollup/rollup-darwin-x64': 4.36.0
1219 | '@rollup/rollup-freebsd-arm64': 4.36.0
1220 | '@rollup/rollup-freebsd-x64': 4.36.0
1221 | '@rollup/rollup-linux-arm-gnueabihf': 4.36.0
1222 | '@rollup/rollup-linux-arm-musleabihf': 4.36.0
1223 | '@rollup/rollup-linux-arm64-gnu': 4.36.0
1224 | '@rollup/rollup-linux-arm64-musl': 4.36.0
1225 | '@rollup/rollup-linux-loongarch64-gnu': 4.36.0
1226 | '@rollup/rollup-linux-powerpc64le-gnu': 4.36.0
1227 | '@rollup/rollup-linux-riscv64-gnu': 4.36.0
1228 | '@rollup/rollup-linux-s390x-gnu': 4.36.0
1229 | '@rollup/rollup-linux-x64-gnu': 4.36.0
1230 | '@rollup/rollup-linux-x64-musl': 4.36.0
1231 | '@rollup/rollup-win32-arm64-msvc': 4.36.0
1232 | '@rollup/rollup-win32-ia32-msvc': 4.36.0
1233 | '@rollup/rollup-win32-x64-msvc': 4.36.0
1234 | fsevents: 2.3.3
1235 |
1236 | scheduler@0.23.2:
1237 | dependencies:
1238 | loose-envify: 1.4.0
1239 |
1240 | semver@6.3.1: {}
1241 |
1242 | shebang-command@2.0.0:
1243 | dependencies:
1244 | shebang-regex: 3.0.0
1245 |
1246 | shebang-regex@3.0.0: {}
1247 |
1248 | signal-exit@4.1.0: {}
1249 |
1250 | source-map-js@1.2.1: {}
1251 |
1252 | strip-final-newline@4.0.0: {}
1253 |
1254 | unicorn-magic@0.3.0: {}
1255 |
1256 | update-browserslist-db@1.1.3(browserslist@4.24.4):
1257 | dependencies:
1258 | browserslist: 4.24.4
1259 | escalade: 3.2.0
1260 | picocolors: 1.1.1
1261 |
1262 | vite@6.2.2:
1263 | dependencies:
1264 | esbuild: 0.25.1
1265 | postcss: 8.5.3
1266 | rollup: 4.36.0
1267 | optionalDependencies:
1268 | fsevents: 2.3.3
1269 |
1270 | which@2.0.2:
1271 | dependencies:
1272 | isexe: 2.0.0
1273 |
1274 | yallist@3.1.1: {}
1275 |
1276 | yoctocolors@2.1.1: {}
1277 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.res:
--------------------------------------------------------------------------------
1 | let authenticated: (Shape.User.t => React.element, option) => React.element = (
2 | getPage,
3 | user,
4 | ) =>
5 | switch user {
6 | | Some(s) => getPage(s)
7 | | None =>
8 | Link.home->Link.push
9 | React.null
10 | }
11 |
12 | @react.component
13 | let make = () => {
14 | let (currentUser, setCurrentUser) = Hook.useCurrentUser()
15 | let route = Route.useRoute()
16 |
17 | switch currentUser {
18 | | Init | Loading => React.null
19 | | Reloading(user) | Complete(user) =>
20 | <>
21 |
22 | {switch route {
23 | | Settings => authenticated(user => , user)
24 | | Login =>
25 | | Register =>
26 | | CreateArticle => authenticated(_user => , user)
27 | | EditArticle(slug) => authenticated(_user => , user)
28 | | Article(slug) =>
29 | | Profile(username) =>
30 | | Favorited(username) =>
31 | | Home =>
32 | }}
33 |
34 | >
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/App.resi:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make: unit => React.element
3 |
--------------------------------------------------------------------------------
/src/component/ErrorDetails.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~label: string, ~error: option>) =>
3 | error
4 | ->Option.map(message =>
5 | message
6 | ->Array.map(message => {`${label} ${message}`->React.string} )
7 | ->React.array
8 | )
9 | ->Option.getWithDefault(React.null)
10 |
--------------------------------------------------------------------------------
/src/component/Link.res:
--------------------------------------------------------------------------------
1 | type location'
2 |
3 | type onClickAction =
4 | | Location(location')
5 | | CustomFn(unit => unit)
6 |
7 | let customFn = fn => CustomFn(fn)
8 | let location = location => Location(location)
9 |
10 | external make: string => location' = "%identity"
11 | external toString: location' => string = "%identity"
12 |
13 | let home = make("/")
14 | let settings = make("/#/settings")
15 | let register = make("/#/register")
16 | let login = make("/#/login")
17 | let createArticle = make("/#/editor")
18 | let editArticle = (~slug) => make(`/#/editor/${slug}`)
19 | let article = (~slug) => make(`/#/article/${slug}`)
20 | let profile = (~username) => make(`/#/profile/${username}`)
21 | let favorited = (~username) => make(`/#/profile/${username}/favorites`)
22 |
23 | let push: location' => unit = location => location->toString->RescriptReactRouter.push
24 |
25 | let availableIf: (bool, onClickAction) => onClickAction = (available, target) =>
26 | available ? target : CustomFn(ignore)
27 |
28 | let handleClick = (onClick, event) => {
29 | switch onClick {
30 | | Location(location) =>
31 | if Utils.isMouseRightClick(event) {
32 | event->ReactEvent.Mouse.preventDefault
33 | location->toString->RescriptReactRouter.push
34 | }
35 | | CustomFn(fn) => fn()
36 | }
37 | ignore()
38 | }
39 |
40 | @react.component
41 | let make = (~className="", ~style=ReactDOM.Style.make(), ~onClick, ~children) => {
42 | let href = switch onClick {
43 | | Location(location) => Some(location->toString)
44 | | CustomFn(_fn) => None
45 | }
46 | children
47 | }
48 |
49 | module Button = {
50 | @react.component
51 | let make = (~className="", ~style=ReactDOM.Style.make(), ~onClick, ~disabled=false, ~children) =>
52 | children
53 | }
54 |
--------------------------------------------------------------------------------
/src/component/Pagination.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~limit: int, ~offset: int, ~total: int, ~onClick: int => unit) =>
3 | if total == 0 {
4 | React.null
5 | } else {
6 | let pages = Float.toInt(Math.ceil(Int.toFloat(total) /. Int.toFloat(limit))) - 1
7 |
8 |
9 |
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/src/component/Route.res:
--------------------------------------------------------------------------------
1 | type t =
2 | | Home
3 | | Login
4 | | Register
5 | | CreateArticle
6 | | EditArticle(string)
7 | | Article(string)
8 | | Profile(string)
9 | | Favorited(string)
10 | | Settings
11 |
12 | let useRoute: unit => t = () => {
13 | let url = RescriptReactRouter.useUrl()
14 | let hash = url.hash->String.split("/")
15 |
16 | switch hash {
17 | | ["", "settings"] => Settings
18 | | ["", "login"] => Login
19 | | ["", "register"] => Register
20 | | ["", "editor"] => CreateArticle
21 | | ["", "editor", slug] => EditArticle(slug)
22 | | ["", "article", slug] => Article(slug)
23 | | ["", "profile", username] => Profile(username)
24 | | ["", "profile", username, "favorites"] => Favorited(username)
25 | | _ => Home
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/component/Security.res:
--------------------------------------------------------------------------------
1 | module AnonymousOnly = {
2 | @react.component
3 | let make = (~user: option, ~children) =>
4 | switch user {
5 | | Some(_) => React.null
6 | | None => children
7 | }
8 | }
9 |
10 | module AuthenticatedOnly = {
11 | @react.component
12 | let make = (~user: option, ~children) =>
13 | switch user {
14 | | None => React.null
15 | | Some(_) => children
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/component/Spinner.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = () =>
3 |
9 |
--------------------------------------------------------------------------------
/src/component/WithTestId.res:
--------------------------------------------------------------------------------
1 | // source code from https://github.com/reasonml/reason-react/issues/230#issuecomment-562446425
2 | @react.component
3 | let make = (~id: string, ~children) => React.cloneElement(children, {"data-testid": id})
4 |
--------------------------------------------------------------------------------
/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihchi/rescript-react-realworld-example-app/39e136f7666b0b3871c9c1e4bcd1839cd879b41b/src/favicon.ico
--------------------------------------------------------------------------------
/src/main.res:
--------------------------------------------------------------------------------
1 | ReactDOM.querySelector("#root")
2 | ->Option.getExn
3 | ->ReactDOM.Client.createRoot
4 | ->ReactDOM.Client.Root.render(
5 |
6 |
7 | ,
8 | )
9 |
--------------------------------------------------------------------------------
/src/page/Article/Article.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~slug: string, ~user: option) => {
3 | let (articleAndTagList, _setArticle) = Hook.useArticle(~slug)
4 | let article = articleAndTagList->AsyncResult.map(((article, _tagList, _editor)) => article)
5 | let (comments, busyComments, deleteComment, setComments) = Hook.useComments(~slug)
6 | let (follow, onFollowClick) = Hook.useFollow(~article, ~user)
7 | let (favorite, onFavoriteClick) = Hook.useFavorite(~article, ~user)
8 | let (isDeleteBusy, onDeleteClick) = Hook.useDeleteArticle(~article, ~user)
9 | let isAuthor = switch (user, article) {
10 | | (Some(u), Reloading(Ok(a)) | Complete(Ok(a))) => u.username == a.author.username
11 | | (Some(_), Init | Loading | Reloading(Error(_)) | Complete(Error(_)))
12 | | (None, Init | Loading | Reloading(_) | Complete(_)) => false
13 | }
14 |
15 |
16 |
17 |
18 |
19 | {article
20 | ->AsyncResult.getOk
21 | ->Option.map((ok: Shape.Article.t) => ok.title)
22 | ->Option.map(title => title->React.string)
23 | ->Option.getWithDefault(React.null)}
24 |
25 |
26 |
27 |
33 | {isAuthor
34 | ?
35 | :
}
36 | {isAuthor
37 | ?
38 | :
}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {switch article {
47 | | Init | Loading =>
48 | | Reloading(Ok({body})) | Complete(Ok({body})) =>
49 |
54 | | Reloading(Error(_error)) | Complete(Error(_error)) => "ERROR"->React.string
55 | }}
56 |
57 | {switch article {
58 | | Init | Loading | Reloading(Error(_)) | Complete(Error(_)) => React.null
59 | | Reloading(Ok({tagList})) | Complete(Ok({tagList})) =>
60 | }}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
73 | {isAuthor
74 | ?
75 | :
}
76 | {isAuthor
77 | ?
78 | :
}
79 |
80 |
81 |
82 |
83 | {switch user {
84 | | Some({image}) =>
85 |
Option.getWithDefault("")} slug setComments />
86 | | None =>
87 |
88 | Link.location}>
89 | {"Sign in"->React.string}
90 |
91 | {" or "->React.string}
92 | Link.location}>
93 | {"sign up"->React.string}
94 |
95 | {" to add comments on this article."->React.string}
96 |
97 | }}
98 |
99 |
100 |
101 |
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleAuthorAvatar.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~article) =>
3 | article
4 | ->AsyncResult.getOk
5 | ->Option.map((ok: Shape.Article.t) => ok.author)
6 | ->Option.map((author: Shape.Author.t) =>
7 | Link.location}>
8 | {switch author.image {
9 | | "" =>
10 | | src =>
11 | }}
12 |
13 | )
14 | ->Option.getWithDefault(React.null)
15 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleAuthorName.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~article) =>
3 | article
4 | ->AsyncResult.getOk
5 | ->Option.map((ok: Shape.Article.t) => ok.author)
6 | ->Option.map((author: Shape.Author.t) =>
7 | Link.location} className="author">
8 | {author.username->React.string}
9 |
10 | )
11 | ->Option.getWithDefault(React.null)
12 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleComments.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (
3 | ~slug: string,
4 | ~data: AsyncResult.t, AppError.t>,
5 | ~user: option,
6 | ~onDeleteClick: (~slug: string, ~id: int) => unit,
7 | ~busy: Belt.Set.Int.t,
8 | ) =>
9 | switch data {
10 | | Init | Loading | Reloading(Error(_)) =>
11 | | Complete(Error(_)) => "ERROR"->React.string
12 | | Reloading(Ok(comments)) | Complete(Ok(comments)) =>
13 | comments
14 | ->Array.map((comment: Shape.Comment.t) => {
15 | let isAPIBusy = Belt.Set.Int.has(busy, comment.id)
16 |
17 | string_of_int}>
18 |
19 |
{comment.body->React.string}
20 |
21 |
22 |
Link.location}
24 | className="comment-author"
25 | style={ReactDOM.Style.make(~marginRight="7px", ())}>
26 | {switch comment.author.image {
27 | | "" =>
28 | | src =>
29 | }}
30 |
31 |
Link.location}
33 | className="comment-author">
34 | {comment.author.username->React.string}
35 |
36 |
{comment.createdAt->Utils.formatDate->React.string}
37 |
38 | {
39 | // TODO: implement "edit" icon
40 | false ? : React.null
41 | }
42 | {switch user {
43 | | Some({username}) if username == comment.author.username =>
44 |
47 | if !isAPIBusy && Utils.isMouseRightClick(event) {
48 | onDeleteClick(~slug, ~id=comment.id)
49 | }}
50 | />
51 | | Some(_) | None => React.null
52 | }}
53 |
54 |
55 |
56 | })
57 | ->React.array
58 | }
59 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleDate.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~article) =>
3 | article
4 | ->AsyncResult.getOk
5 | ->Option.map((ok: Shape.Article.t) => ok.createdAt)
6 | ->Option.map(createdAt => createdAt->Utils.formatDate->React.string)
7 | ->Option.getWithDefault(React.null)
8 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleDeleteButton.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~isBusy, ~onClick) =>
3 |
7 |
11 | {"Delete Article"->React.string}
12 |
13 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleEditButton.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data) =>
3 | data
4 | ->AsyncResult.getOk
5 | ->Option.map((ok: Shape.Article.t) =>
6 | Link.location}>
9 |
10 | {"Edit Article"->React.string}
11 |
12 | )
13 | ->Option.getWithDefault(React.null)
14 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleFavoriteButton.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data: AsyncData.t<(bool, int, string)>, ~onClick: Link.onClickAction) =>
3 | "btn btn-sm btn-outline-primary"
9 | | Reloading((true, _, _)) | Complete((true, _, _)) => "btn btn-sm btn-primary"
10 | }}
11 | style={ReactDOM.Style.make(~marginLeft="5px", ())}
12 | onClick={switch data {
13 | | Init | Loading | Reloading((_, _, _)) => Link.customFn(ignore)
14 | | Complete((_, _, _)) => onClick
15 | }}>
16 |
20 | {switch data {
21 | | Init | Loading => React.null
22 | | Reloading((favorited, favoritesCount, _slug))
23 | | Complete((favorited, favoritesCount, _slug)) =>
24 | <>
25 | {(favorited ? "Unfavorite Article " : "Favorite Article ")->React.string}
26 | {`(${favoritesCount->Int.toString})`->React.string}
27 | >
28 | }}
29 |
30 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleFollowButton.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data: AsyncData.t<(string, bool)>, ~onClick: Link.onClickAction) =>
3 | "btn btn-sm btn-outline-secondary"
9 | | Reloading((_, true)) | Complete((_, true)) => "btn btn-sm btn-secondary"
10 | }}
11 | onClick={switch data {
12 | | Init | Loading | Reloading((_, _)) => Link.customFn(ignore)
13 | | Complete((_, _)) => onClick
14 | }}>
15 |
19 | {switch data {
20 | | Init | Loading => React.null
21 | | Reloading((username, following)) | Complete((username, following)) =>
22 | `${following ? "Unfollow" : "Follow"} ${username}`->React.string
23 | }}
24 |
25 |
--------------------------------------------------------------------------------
/src/page/Article/ArticlePostComment.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (
3 | ~slug: string,
4 | ~image: string,
5 | ~setComments: (
6 | AsyncResult.t, AppError.t> => AsyncResult.t<
7 | array,
8 | AppError.t,
9 | >
10 | ) => unit,
11 | ) => {
12 | let (comment, setComment) = React.useState(() => AsyncData.complete(""))
13 | let body = comment->AsyncData.getValue->Option.getWithDefault("")
14 | let isCommentValid =
15 | comment->AsyncData.getValue->Option.map(v => String.trim(v) != "")->Option.getWithDefault(false)
16 |
17 | let handlePostCommentClick = async () => {
18 | if isCommentValid && AsyncData.isComplete(comment) {
19 | setComment(AsyncData.toBusy)
20 | switch await API.addComment(~slug, ~body, ()) {
21 | | Ok(comment) =>
22 | setComments(prev =>
23 | prev->AsyncResult.map(comments => {
24 | let _ = comments->Array.unshift(comment)
25 | comments
26 | })
27 | )
28 | setComment(_prev => AsyncData.complete(""))
29 | | Error(_error) => setComment(AsyncData.toIdle)
30 | }
31 | }
32 | }
33 |
34 |
65 | }
66 |
--------------------------------------------------------------------------------
/src/page/Article/ArticleTagList.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data: array) =>
3 |
4 | {data
5 | ->Array.map(tag =>
6 | {tag->React.string}
7 | )
8 | ->React.array}
9 |
10 |
--------------------------------------------------------------------------------
/src/page/Editor.res:
--------------------------------------------------------------------------------
1 | let parseTagList = (str: string): array =>
2 | str->String.split(",")->Array.map(String.trim)->Array.filter(v => String.length(v) > 0)
3 |
4 | module Form = {
5 | @react.component
6 | let make = (~data, ~setData, ~onSubmit: ((Shape.Article.t, string)) => unit) => {
7 | let isBusy = data->AsyncResult.isBusy
8 | let error = data->AsyncResult.getOk->Option.flatMap(((_article, _tagList, error)) => error)
9 |
10 | <>
11 | {switch error {
12 | | None => React.null
13 | | Some(error: Shape.Editor.t) =>
14 |
19 | }}
20 |
21 |
22 |
23 | AsyncResult.getOk
30 | ->Option.map(((
31 | article: Shape.Article.t,
32 | _tagList: string,
33 | _error: option,
34 | )) => article.title)
35 | ->Option.getWithDefault("")}
36 | onChange={event => {
37 | let title = ReactEvent.Form.target(event)["value"]
38 | setData(prev =>
39 | prev->AsyncResult.map(((
40 | article: Shape.Article.t,
41 | tagList: string,
42 | error: option,
43 | )) => ({...article, title}, tagList, error))
44 | )
45 | }}
46 | />
47 |
48 |
49 | AsyncResult.getOk
56 | ->Option.map(((
57 | article: Shape.Article.t,
58 | _tagList: string,
59 | _error: option,
60 | )) => article.description)
61 | ->Option.getWithDefault("")}
62 | onChange={event => {
63 | let description = ReactEvent.Form.target(event)["value"]
64 | setData(prev =>
65 | prev->AsyncResult.map(((
66 | article: Shape.Article.t,
67 | tagList: string,
68 | error: option,
69 | )) => ({...article, description}, tagList, error))
70 | )
71 | }}
72 | />
73 |
74 |
75 | AsyncResult.getOk
82 | ->Option.map(((
83 | article: Shape.Article.t,
84 | _tagList: string,
85 | _error: option,
86 | )) => article.body)
87 | ->Option.getWithDefault("")}
88 | onChange={event => {
89 | let body = ReactEvent.Form.target(event)["value"]
90 | setData(prev =>
91 | prev->AsyncResult.map(((
92 | article: Shape.Article.t,
93 | tagList: string,
94 | error: option,
95 | )) => ({...article, body}, tagList, error))
96 | )
97 | }}
98 | />
99 |
100 |
101 | AsyncResult.getOk
108 | ->Option.map(((
109 | _article: Shape.Article.t,
110 | tagList: string,
111 | _error: option,
112 | )) => tagList)
113 | ->Option.getWithDefault("")}
114 | onChange={event => {
115 | let tagList = ReactEvent.Form.target(event)["value"]
116 | setData(prev =>
117 | prev->AsyncResult.map(((
118 | article: Shape.Article.t,
119 | _tagList: string,
120 | error: option,
121 | )) => (article, tagList, error))
122 | )
123 | }}
124 | />
125 |
126 |
127 | {
132 | event->ReactEvent.Mouse.preventDefault
133 | event->ReactEvent.Mouse.stopPropagation
134 |
135 | if isBusy {
136 | ignore()
137 | } else {
138 | switch data->AsyncResult.getOk {
139 | | Some((
140 | article: Shape.Article.t,
141 | tagList: string,
142 | _error: option,
143 | )) =>
144 | onSubmit((article, tagList))
145 | ignore()
146 | | None => ignore()
147 | }
148 | }
149 | }}>
150 | {"Publish Article"->React.string}
151 |
152 |
153 |
154 | >
155 | }
156 | }
157 |
158 | module Create = {
159 | let empty = (
160 | {
161 | Shape.Article.slug: "",
162 | title: "",
163 | description: "",
164 | body: "",
165 | tagList: [],
166 | createdAt: Js.Date.make(),
167 | updatedAt: Js.Date.make(),
168 | favorited: false,
169 | favoritesCount: 0,
170 | author: {
171 | Shape.Author.username: "",
172 | bio: None,
173 | image: "",
174 | following: Some(false),
175 | },
176 | },
177 | "",
178 | None,
179 | )
180 |
181 | @react.component
182 | let make = () => {
183 | let (article, setArticle) = React.useState(() => AsyncResult.completeOk(empty))
184 |
185 | let handleSumbit = async (~article, ~tagList) => {
186 | setArticle(AsyncResult.toBusy)
187 |
188 | switch await API.article(~action=Create({...article, tagList: parseTagList(tagList)}), ()) {
189 | | Ok(ok: Shape.Article.t) =>
190 | Link.article(~slug=ok.slug)->Link.push
191 | setArticle(prev => prev->AsyncResult.toIdle)
192 | | Error(AppError.Fetch((_code, _message, #json(json)))) =>
193 | try {
194 | let result =
195 | json
196 | ->Js.Json.decodeObject
197 | ->Option.getExn
198 | ->Js.Dict.get("errors")
199 | ->Option.getExn
200 | ->Shape.Editor.decode
201 | switch result {
202 | | Ok(errors) =>
203 | setArticle(prev =>
204 | prev
205 | ->AsyncData.toIdle
206 | ->AsyncResult.map(((article, tagList, _error)) => (article, tagList, Some(errors)))
207 | )
208 | | Error(_e) => ignore()
209 | }
210 | } catch {
211 | | _ => Js.log("Button.UpdateSettings: failed to decode json")
212 | }
213 | | Error(Fetch((_, _, #text(_)))) | Error(Decode(_)) => setArticle(AsyncResult.toIdle)
214 | }
215 | }
216 |
217 | {
221 | handleSumbit(~article, ~tagList)->ignore
222 | }}
223 | />
224 | }
225 | }
226 |
227 | module Edit = {
228 | @react.component
229 | let make = (~slug: string) => {
230 | let (article, setArticle) = Hook.useArticle(~slug)
231 |
232 | let handleSubmit = async (~article, ~tagList) => {
233 | setArticle(AsyncResult.toBusy)
234 | let _ = await API.article(
235 | ~action=Update(slug, {...article, tagList: parseTagList(tagList)}),
236 | (),
237 | )
238 | setArticle(AsyncResult.toIdle)
239 | }
240 |
241 | {
245 | handleSubmit(~article, ~tagList)->ignore
246 | }}
247 | />
248 | }
249 | }
250 |
251 | @react.component
252 | let make = (~slug: option=?) =>
253 |
254 |
255 |
256 |
257 | {switch slug {
258 | | Some(slug) =>
259 | | None =>
260 | }}
261 |
262 |
263 |
264 |
265 |
--------------------------------------------------------------------------------
/src/page/Footer.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = () => <>
3 |
20 |
21 | {"Fork on GitHub"->React.string}
22 |
23 |
35 | >
36 |
--------------------------------------------------------------------------------
/src/page/Header.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~user: option) => {
3 | let currentUser = user->Option.getWithDefault(Shape.User.empty)
4 |
5 |
6 |
7 |
Link.location}>
8 | {"conduit"->React.string}
9 |
10 |
11 |
12 | Link.location}>
13 | {"Home"->React.string}
14 |
15 |
16 |
17 |
18 | Link.location}>
19 | {"Sign in"->React.string}
20 |
21 |
22 |
23 | Link.location}>
24 | {"Sign up"->React.string}
25 |
26 |
27 |
28 |
29 |
30 | Link.location}>
31 |
32 | {" New Post"->React.string}
33 |
34 |
35 |
36 | Link.location}>
37 |
38 | {" Settings"->React.string}
39 |
40 |
41 |
42 | Link.location}>
45 | {currentUser.username->React.string}
46 |
47 |
48 |
49 |
50 |
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/src/page/Home.res:
--------------------------------------------------------------------------------
1 | module ArticlePreview = HomeArticlePreview
2 | module PopularTags = HomePopularTags
3 |
4 | let useFeedType = (~user: option) =>
5 | React.useState(() =>
6 | switch user {
7 | | None => Shape.FeedType.Global(10, 0)
8 | | Some(_) => Shape.FeedType.Personal(10, 0)
9 | }
10 | )
11 |
12 | @react.component
13 | let make = (~user: option) => {
14 | let (feedType, setFeedType) = useFeedType(~user)
15 | let (articles, setArticles) = Hook.useArticles(~feedType)
16 | let tags = Hook.useTags()
17 | let (toggleFavoriteBusy, onToggleFavorite) = Hook.useToggleFavorite(~setArticles, ~user)
18 |
19 |
20 |
21 |
22 |
{"conduit"->React.string}
23 |
{"A place to share your knowledge."->React.string}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
88 |
89 | {switch articles {
90 | | Init => React.null
91 | | Loading => React.null
92 | | Reloading(Ok({articles})) | Complete(Ok({articles})) =>
93 | articles
94 | ->Array.map(item =>
95 |
Belt.Set.String.has(_, item.slug)}
100 | />
101 | )
102 | ->React.array
103 | | Reloading(Error(_error)) | Complete(Error(_error)) => React.string("ERROR")
104 | }}
105 | {switch feedType {
106 | | Tag(_, limit, offset) | Global(limit, offset) | Personal(limit, offset) =>
107 | let total = switch articles {
108 | | Init | Loading | Reloading(Error(_)) | Complete(Error(_)) => 0
109 | | Reloading(Ok({articlesCount})) | Complete(Ok({articlesCount})) => articlesCount
110 | }
111 |
112 |
117 | setFeedType(x =>
118 | switch x {
119 | | Tag(tag, limit, _) => Tag(tag, limit, offset)
120 | | Global(limit, _) => Global(limit, offset)
121 | | Personal(limit, _) => Personal(limit, offset)
122 | }
123 | )}
124 | />
125 | }}
126 |
127 |
128 |
129 |
132 | setFeedType(x =>
133 | switch x {
134 | | Tag(_, limit, offset) => Tag(tag, limit, offset)
135 | | Global(_) | Personal(_) => Tag(tag, 10, 0)
136 | }
137 | )}
138 | />
139 |
140 |
141 |
142 |
143 |
144 | }
145 |
--------------------------------------------------------------------------------
/src/page/HomeArticlePreview.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data: Shape.Article.t, ~onToggleFavorite, ~isFavoriteBusy) =>
3 |
4 |
5 |
Link.location}>
6 |
7 |
8 |
9 | Link.location}>
11 | {data.author.username->React.string}
12 |
13 | {data.createdAt->Date.toLocaleString->React.string}
14 |
15 |
21 | onToggleFavorite(
22 | ~action=data.favorited
23 | ? API.Action.Unfavorite(data.slug)
24 | : API.Action.Favorite(data.slug),
25 | )}>
26 |
30 | {data.favoritesCount->Int.toString->React.string}
31 |
32 |
33 |
Link.location} className="preview-link">
34 |
{data.title->React.string}
35 |
{data.description->React.string}
36 |
{"Read more..."->React.string}
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/page/HomePopularTags.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~data: AsyncResult.t, ~onClick) => <>
3 | {"Popular Tags"->React.string}
4 |
5 |
28 |
29 | >
30 |
--------------------------------------------------------------------------------
/src/page/Login.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~setUser) => {
3 | let (data, setData) = React.useState(() => AsyncData.complete(("", "", None)))
4 | let isBusy = data->AsyncData.isBusy
5 | let (email, password, error) = data->AsyncData.getValue->Option.getWithDefault(("", "", None))
6 |
7 | let handleLoginClick = async () => {
8 | setData(AsyncData.toBusy)
9 |
10 | switch await API.login(~email, ~password, ()) {
11 | | Ok(user: Shape.User.t) =>
12 | setUser(_prev => Some(user)->AsyncData.complete)
13 | setData(AsyncData.toIdle)
14 | Utils.setCookie(Constant.Auth.tokenCookieName, Some(user.token))
15 | Link.home->Link.push
16 | | Error(AppError.Fetch((_code, _message, #json(json)))) =>
17 | try {
18 | let result =
19 | json
20 | ->Js.Json.decodeObject
21 | ->Option.getExn
22 | ->Js.Dict.get("errors")
23 | ->Option.getExn
24 | ->Shape.Login.decode
25 |
26 | switch result {
27 | | Ok(errors) =>
28 | setData(prev =>
29 | prev
30 | ->AsyncData.toIdle
31 | ->AsyncData.map(((email, password, _error)) => (email, password, errors))
32 | )
33 | | Error(_e) => ignore()
34 | }
35 | } catch {
36 | | _ => Js.log("Button.SignIn: failed to decode json")
37 | }
38 | | Error(Fetch((_, _, #text(_)))) | Error(Decode(_)) => setData(AsyncData.toIdle)
39 | }
40 | }
41 |
42 |
43 |
44 |
45 |
46 |
{"Sign in"->React.string}
47 |
48 | Link.location}> {"Need an account?"->React.string}
49 |
50 | {switch error {
51 | | Some(messages) =>
52 |
53 | {messages
54 | ->Array.map(message =>
55 | {`email or password ${message}`->React.string}
56 | )
57 | ->React.array}
58 |
59 | | None => React.null
60 | }}
61 |
62 |
63 | {
70 | let email = ReactEvent.Form.target(event)["value"]
71 | setData(prev =>
72 | prev->AsyncData.map(((_email, password, error)) => (email, password, error))
73 | )
74 | }}
75 | />
76 |
77 |
78 | {
85 | let password = ReactEvent.Form.target(event)["value"]
86 | setData(prev =>
87 | prev->AsyncData.map(((email, _password, error)) => (email, password, error))
88 | )
89 | }}
90 | />
91 |
92 | {
96 | event->ReactEvent.Mouse.preventDefault
97 | event->ReactEvent.Mouse.stopPropagation
98 | handleLoginClick()->ignore
99 | }}>
100 | {"Sign in"->React.string}
101 |
102 |
103 |
104 |
105 |
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/src/page/Profile.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (~viewMode: Shape.Profile.viewMode, ~user: option) => {
3 | let (viewMode, changeOffset) = Hook.useViewMode(~route=viewMode)
4 | let (username, limit, offset) = switch viewMode {
5 | | Author(username, limit, offset) => (username, limit, offset)
6 | | Favorited(username, limit, offset) => (username, limit, offset)
7 | }
8 | let profile = Hook.useProfile(~username)
9 | let (articles, setArticles) = Hook.useArticlesInProfile(~viewMode)
10 | let (follow, onFollowClick) = Hook.useFollowInProfile(~profile, ~user)
11 | let (toggleFavoriteBusy, onToggleFavorite) = Hook.useToggleFavorite(~setArticles, ~user)
12 | let isArticlesBusy = articles->AsyncResult.isBusy
13 |
14 |
15 |
16 |
17 |
18 |
19 | {profile
20 | ->AsyncResult.getOk
21 | ->Option.flatMap((user: Shape.Author.t) => user.image == "" ? None : Some(user.image))
22 | ->Option.map(src =>
)
23 | ->Option.getWithDefault(
)}
24 |
25 | {switch profile {
26 | | Init | Loading | Reloading(Error(_)) | Complete(Error(_)) => "..."
27 | | Reloading(Ok(user)) | Complete(Ok(user)) => user.username
28 | }->React.string}
29 |
30 | {switch profile {
31 | | Init | Loading | Reloading(Error(_)) | Complete(Error(_)) => React.null
32 | | Reloading(Ok(user)) | Complete(Ok(user)) =>
33 | user.bio->Option.map(bio => bio->React.string)->Option.getWithDefault(React.null)
34 | }}
35 | {switch profile {
36 | | Init | Loading | Reloading(Error(_)) | Complete(Error(_)) => React.null
37 | | Reloading(Ok(_)) | Complete(Ok(_)) =>
38 |
AsyncResult.isBusy}
40 | className={switch follow {
41 | | Init
42 | | Loading
43 | | Reloading((_, false))
44 | | Complete((_, false)) => "btn btn-sm btn-outline-secondary action-btn"
45 | | Reloading((_, true))
46 | | Complete((_, true)) => "btn btn-sm btn-secondary action-btn"
47 | }}
48 | onClick={switch (follow, user) {
49 | | (Init, Some(_) | None)
50 | | (Loading, Some(_) | None)
51 | | (Reloading((_, _)), Some(_) | None) =>
52 | Link.customFn(ignore)
53 | | (Complete((username, _)), user) =>
54 | user
55 | ->Option.flatMap((ok: Shape.User.t) =>
56 | if ok.username == username {
57 | Some(Link.settings->Link.location)
58 | } else {
59 | None
60 | }
61 | )
62 | ->Option.getWithDefault(onFollowClick)
63 | }}>
64 | {switch (follow, user) {
65 | | (Init, Some(_) | None) =>
66 |
69 | | (Loading, Some(_) | None) | (Reloading((_, _)), _) =>
70 |
71 | | (Complete((username, _following)), user) =>
72 | user
73 | ->Option.flatMap((ok: Shape.User.t) =>
74 | if ok.username == username {
75 | Some(
76 | ,
79 | )
80 | } else {
81 | None
82 | }
83 | )
84 | ->Option.getWithDefault(
85 | ,
88 | )
89 | }}
90 | {switch (follow, user) {
91 | | (Init, Some(_) | None) | (Loading, Some(_) | None) => "..."->React.string
92 | | (Reloading((username, following)), user)
93 | | (Complete((username, following)), user) =>
94 | user
95 | ->Option.flatMap((ok: Shape.User.t) =>
96 | if ok.username == username {
97 | Some("Edit Profile Settings")
98 | } else {
99 | None
100 | }
101 | )
102 | ->Option.getWithDefault(` ${following ? "Unfollow" : "Follow"} ${username}`)
103 | ->React.string
104 | }}
105 |
106 | }}
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | "nav-link active"
120 | | Favorited(_) => "nav-link"
121 | }}
122 | onClick={Link.availableIf(
123 | !isArticlesBusy &&
124 | switch viewMode {
125 | | Author(_) => false
126 | | Favorited(_) => true
127 | },
128 | Link.Location(Link.profile(~username)),
129 | )}>
130 | {"My Articles"->React.string}
131 |
132 |
133 |
134 | "nav-link"
137 | | Favorited(_) => "nav-link active"
138 | }}
139 | onClick={Link.availableIf(
140 | !isArticlesBusy &&
141 | switch viewMode {
142 | | Author(_) => true
143 | | Favorited(_) => false
144 | },
145 | Link.Location(Link.favorited(~username)),
146 | )}>
147 | {"Favorited Articles"->React.string}
148 |
149 |
150 | {if articles->AsyncResult.isBusy {
151 |
152 |
153 |
154 | } else {
155 | React.null
156 | }}
157 |
158 |
159 | {switch articles {
160 | | Init | Loading => React.null
161 | | Reloading(Error(_)) | Complete(Error(_)) => "ERROR"->React.string
162 | | Reloading(Ok(ok)) | Complete(Ok(ok)) =>
163 | <>
164 | {ok.articles
165 | ->Array.map((article: Shape.Article.t) => {
166 | let isFavoriteBusy = toggleFavoriteBusy->Belt.Set.String.has(_, article.slug)
167 |
168 |
169 |
170 |
Link.location}>
171 | {switch article.author.image {
172 | | "" =>
173 | | src =>
174 | }}
175 |
176 |
177 | Link.location}>
180 | {article.author.username->React.string}
181 |
182 |
183 | {article.createdAt->Utils.formatDate->React.string}
184 |
185 |
186 |
192 | onToggleFavorite(
193 | ~action=article.favorited
194 | ? API.Action.Unfavorite(article.slug)
195 | : API.Action.Favorite(article.slug),
196 | )
197 | )}>
198 |
202 | {article.favoritesCount->string_of_int->React.string}
203 |
204 |
205 |
Link.location}>
208 |
{article.title->React.string}
209 |
{article.description->React.string}
210 |
{"Read more..."->React.string}
211 | {switch article.tagList {
212 | | [] => React.null
213 | | tagList =>
214 |
215 | {tagList
216 | ->Array.map(tag =>
217 |
218 | {tag->React.string}
219 |
220 | )
221 | ->React.array}
222 |
223 | }}
224 |
225 |
226 | })
227 | ->React.array}
228 |
AsyncResult.isBusy ? ignore : changeOffset}
233 | />
234 | >
235 | }}
236 |
237 |
238 |
239 |
240 | }
241 |
--------------------------------------------------------------------------------
/src/page/Register.res:
--------------------------------------------------------------------------------
1 | type t = {
2 | username: string,
3 | email: string,
4 | password: string,
5 | }
6 |
7 | let empty = ({username: "", email: "", password: ""}, None)
8 |
9 | @react.component
10 | let make = (~setUser) => {
11 | let (data, setData) = React.useState(() => AsyncData.complete(empty))
12 | let isBusy = data->AsyncData.isBusy
13 | let (form, error) = data->AsyncData.getValue->Option.getWithDefault(empty)
14 |
15 | let handleSignupClick = async () => {
16 | if isBusy {
17 | ignore()
18 | } else {
19 | setData(AsyncData.toBusy)
20 | switch await API.register(
21 | ~username=form.username,
22 | ~email=form.email,
23 | ~password=form.password,
24 | (),
25 | ) {
26 | | Ok(user: Shape.User.t) =>
27 | setUser(_prev => Some(user)->AsyncData.complete)
28 | setData(AsyncData.toIdle)
29 | Utils.setCookie(Constant.Auth.tokenCookieName, Some(user.token))
30 | Link.home->Link.push
31 | | Error(AppError.Fetch((_code, _message, #json(json)))) =>
32 | try {
33 | let result =
34 | json
35 | ->Js.Json.decodeObject
36 | ->Option.getExn
37 | ->Js.Dict.get("errors")
38 | ->Option.getExn
39 | ->Shape.Register.decode
40 | switch result {
41 | | Ok(errors) =>
42 | setData(prev =>
43 | prev->AsyncData.toIdle->AsyncData.map(((form, _error)) => (form, Some(errors)))
44 | )
45 | | Error(_e) => ignore()
46 | }
47 | } catch {
48 | | _ => Js.log("Button.UpdateSettings: failed to decode json")
49 | }
50 | | Error(Fetch((_, _, #text(_)))) | Error(Decode(_)) => setData(AsyncData.toIdle)
51 | }
52 | }
53 | }
54 |
55 |
131 | }
132 |
--------------------------------------------------------------------------------
/src/page/Settings.res:
--------------------------------------------------------------------------------
1 | @react.component
2 | let make = (
3 | ~user: Shape.User.t,
4 | ~setUser: (AsyncData.t> => AsyncData.t >) => unit,
5 | ) => {
6 | let (result, setResult) = React.useState(() => AsyncData.complete((user, "", None)))
7 | let isBusy = result->AsyncData.isBusy
8 | let (form, password, error) = result->AsyncData.getValue->Option.getWithDefault((user, "", None))
9 |
10 | let updateUser = async (~user, ~password) => {
11 | setResult(AsyncData.toBusy)
12 |
13 | switch await API.updateUser(~user, ~password, ()) {
14 | | Ok(user) =>
15 | setResult(prev =>
16 | prev->AsyncData.toIdle->AsyncData.map(((_user, _password, _error)) => (user, "", None))
17 | )
18 | setUser(prev => prev->AsyncData.map(_prev => Some(user)))
19 | | Error(AppError.Fetch((_code, _message, #json(json)))) =>
20 | try {
21 | let result =
22 | json
23 | ->Js.Json.decodeObject
24 | ->Option.getExn
25 | ->Js.Dict.get("errors")
26 | ->Option.getExn
27 | ->Shape.Settings.decode
28 | switch result {
29 | | Ok(errors) =>
30 | setResult(prev =>
31 | prev
32 | ->AsyncData.toIdle
33 | ->AsyncData.map(((user, _password, _error)) => (user, "", Some(errors)))
34 | )
35 | | Error(_e) => ignore()
36 | }
37 | } catch {
38 | | _ =>
39 | Js.log("Button.UpdateSettings: failed to decode json")
40 | ignore()
41 | }
42 | | Error(Fetch((_, _, #text(_)))) | Error(Decode(_)) => ignore()
43 | }
44 | }
45 |
46 |
47 |
48 |
49 |
50 |
{"Your Settings"->React.string}
51 | {switch error {
52 | | None => React.null
53 | | Some(error: Shape.Settings.t) =>
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | }}
62 |
63 |
64 |
65 | Option.getWithDefault("")}
71 | onChange={event => {
72 | let image = ReactEvent.Form.target(event)["value"]
73 | setResult(prev =>
74 | prev->AsyncData.map(((use: Shape.User.t, password, error)) => (
75 | {...use, image},
76 | password,
77 | error,
78 | ))
79 | )
80 | }}
81 | />
82 |
83 |
84 | {
91 | let username = ReactEvent.Form.target(event)["value"]
92 | setResult(prev =>
93 | prev->AsyncData.map(((user: Shape.User.t, password, error)) => (
94 | {...user, username},
95 | password,
96 | error,
97 | ))
98 | )
99 | }}
100 | />
101 |
102 |
103 | Option.getWithDefault("")}
109 | onChange={event => {
110 | let bio = ReactEvent.Form.target(event)["value"]
111 | setResult(prev =>
112 | prev->AsyncData.map(((user: Shape.User.t, password, error)) => (
113 | {...user, bio},
114 | password,
115 | error,
116 | ))
117 | )
118 | }}
119 | />
120 |
121 |
122 | {
129 | let email = ReactEvent.Form.target(event)["value"]
130 | setResult(prev =>
131 | prev->AsyncData.map(((user: Shape.User.t, password, error)) => (
132 | {...user, email},
133 | password,
134 | error,
135 | ))
136 | )
137 | }}
138 | />
139 |
140 |
141 | {
148 | let password = ReactEvent.Form.target(event)["value"]
149 | setResult(prev =>
150 | prev->AsyncData.map(((user, _password, error)) => (user, password, error))
151 | )
152 | }}
153 | />
154 |
155 | {
159 | event->ReactEvent.Mouse.preventDefault
160 | event->ReactEvent.Mouse.stopPropagation
161 | result
162 | ->AsyncData.tapComplete(((user, password, _error)) => {
163 | updateUser(~user, ~password)->ignore
164 | })
165 | ->ignore
166 | }}>
167 | {"Update Settings"->React.string}
168 |
169 |
170 |
171 |
172 |
{
176 | event->ReactEvent.Mouse.preventDefault
177 | event->ReactEvent.Mouse.stopPropagation
178 | if isBusy {
179 | ignore()
180 | } else {
181 | setUser(_prev => AsyncData.complete(None))
182 | Utils.deleteCookie(Constant.Auth.tokenCookieName)
183 | Link.home->Link.push
184 | }
185 | }}>
186 | {"Or click here to logout."->React.string}
187 |
188 |
189 |
190 |
191 |
192 | }
193 |
--------------------------------------------------------------------------------
/src/shared/API.res:
--------------------------------------------------------------------------------
1 | open Promise
2 | open Fetch
3 |
4 | module Action = {
5 | type article =
6 | | Create(Shape.Article.t)
7 | | Read(string)
8 | | Update(string, Shape.Article.t)
9 | | Delete(string)
10 |
11 | type follow =
12 | | Follow(string)
13 | | Unfollow(string)
14 |
15 | type favorite =
16 | | Favorite(string)
17 | | Unfavorite(string)
18 | }
19 |
20 | type parsedBody = result
21 | type extractedError = result
22 |
23 | let addJwtAuthorization = (): array<(string, string)> => {
24 | Utils.getCookie(Constant.Auth.tokenCookieName)
25 | ->Option.flatMap(snd)
26 | ->Option.map(token => [("Authorization", `Token ${token}`)])
27 | ->Option.getWithDefault([])
28 | }
29 |
30 | let addJsonContentType = (): array<(string, string)> => {
31 | [("Content-Type", "application/json; charset=UTF-8")]
32 | }
33 |
34 | let parseBodyAsJson = (response: Response.t): Promise.t => {
35 | if Response.ok(response) {
36 | response
37 | ->Response.json
38 | ->then(json => json->Ok->resolve)
39 | ->catch(_error => response->Result.Error->resolve)
40 | } else {
41 | response->Result.Error->resolve
42 | }
43 | }
44 |
45 | let extractJsonError = (result: parsedBody): Promise.t => {
46 | switch result {
47 | | Ok(json) => json->Result.Ok->resolve
48 | | Error(resp) =>
49 | resp
50 | ->Response.json
51 | ->then(json => {
52 | let status = Response.status(resp)
53 | let statusText = Response.statusText(resp)
54 | let bodyJson = #json(json)
55 |
56 | AppError.fetch((status, statusText, bodyJson))->Result.Error->resolve
57 | })
58 | }
59 | }
60 |
61 | let extractTextError = (result: parsedBody): Promise.t => {
62 | switch result {
63 | | Ok(json) => json->Result.Ok->resolve
64 | | Error(resp) =>
65 | resp
66 | ->Response.text
67 | ->then(text => {
68 | let status = Response.status(resp)
69 | let statusText = Response.statusText(resp)
70 | let bodyText = #text(text)
71 |
72 | AppError.fetch((status, statusText, bodyText))->Result.Error->resolve
73 | })
74 | }
75 | }
76 |
77 | let article = async (~action: Action.article, ()): result => {
78 | let url = Endpoints.Articles.article(
79 | ~slug=switch action {
80 | | Create(_) => ""
81 | | Read(slug) | Update(slug, _) | Delete(slug) => slug
82 | },
83 | (),
84 | )
85 |
86 | let method = switch action {
87 | | Create(_) => #POST
88 | | Read(_) => #GET
89 | | Update(_) => #PUT
90 | | Delete(_) => #DELETE
91 | }
92 |
93 | let headers =
94 | switch action {
95 | | Create(_) | Update(_) => addJsonContentType()
96 | | Read(_) | Delete(_) => []
97 | }
98 | ->Array.concat(addJwtAuthorization())
99 | ->Headers.Init.array
100 | ->Headers.make
101 |
102 | let body = switch action {
103 | | Create(article) | Update(_, article) =>
104 | let article =
105 | list{
106 | ("title", Js.Json.string(article.title)),
107 | ("description", Js.Json.string(article.description)),
108 | ("body", Js.Json.string(article.body)),
109 | ("tagList", Js.Json.stringArray(article.tagList)),
110 | }
111 | ->Js.Dict.fromList
112 | ->Js.Json.object_
113 |
114 | list{("article", article)}->Js.Dict.fromList->Js.Json.object_->Js.Json.stringify->Body.string
115 | | Read(_) | Delete(_) => Body.none
116 | }
117 |
118 | let res = await fetch(
119 | url,
120 | {
121 | method,
122 | headers,
123 | body,
124 | },
125 | )
126 | let res = await parseBodyAsJson(res)
127 | let res = await extractJsonError(res)
128 |
129 | res->Result.flatMap(json => {
130 | try {
131 | json
132 | ->Js.Json.decodeObject
133 | ->Option.getExn
134 | ->Js.Dict.get("article")
135 | ->Option.getExn
136 | ->Shape.Article.decode
137 | ->AppError.decode
138 | } catch {
139 | | _ => AppError.decode(Error("API.article: failed to decode json"))
140 | }
141 | })
142 | }
143 |
144 | let favoriteArticle = async (~action: Action.favorite, ()): result => {
145 | let url = Endpoints.Articles.favorite(
146 | ~slug=switch action {
147 | | Favorite(slug) => slug
148 | | Unfavorite(slug) => slug
149 | },
150 | (),
151 | )
152 |
153 | let method = switch action {
154 | | Favorite(_slug) => #POST
155 | | Unfavorite(_slug) => #DELETE
156 | }
157 |
158 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
159 |
160 | let res = await fetch(
161 | url,
162 | {
163 | method,
164 | headers,
165 | },
166 | )
167 | let res = await parseBodyAsJson(res)
168 | let res = await extractTextError(res)
169 |
170 | res->Result.flatMap(json =>
171 | try {
172 | json
173 | ->Js.Json.decodeObject
174 | ->Option.getExn
175 | ->Js.Dict.get("article")
176 | ->Option.getExn
177 | ->Shape.Article.decode
178 | ->AppError.decode
179 | } catch {
180 | | _ => AppError.decode(Error("API.favoriteArticle: failed to decode json"))
181 | }
182 | )
183 | }
184 |
185 | let listArticles = async (
186 | ~limit: int=10,
187 | ~offset: int=0,
188 | ~tag: option=?,
189 | ~author: option=?,
190 | ~favorited: option=?,
191 | (),
192 | ): result => {
193 | let url = Endpoints.Articles.root(~limit, ~offset, ~tag?, ~author?, ~favorited?, ())
194 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
195 |
196 | let res = await fetch(
197 | url,
198 | {
199 | headers: headers,
200 | },
201 | )
202 | let res = await parseBodyAsJson(res)
203 | let res = await extractTextError(res)
204 |
205 | res->Result.flatMap(json => json->Shape.Articles.decode->AppError.decode)
206 | }
207 |
208 | let feedArticles = async (~limit: int=10, ~offset: int=0, ()): result<
209 | Shape.Articles.t,
210 | AppError.t,
211 | > => {
212 | let url = Endpoints.Articles.feed(~limit, ~offset, ())
213 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
214 |
215 | let res = await fetch(
216 | url,
217 | {
218 | headers: headers,
219 | },
220 | )
221 |
222 | let res = await parseBodyAsJson(res)
223 | let res = await extractTextError(res)
224 |
225 | res->Result.flatMap(json => json->Shape.Articles.decode->AppError.decode)
226 | }
227 |
228 | let tags = async (): result => {
229 | let url = Endpoints.tags()
230 | let method = #GET
231 |
232 | let res = await fetch(
233 | url,
234 | {
235 | method: method,
236 | },
237 | )
238 |
239 | let res = await parseBodyAsJson(res)
240 | let res = await extractTextError(res)
241 |
242 | res->Result.flatMap(json => json->Shape.Tags.decode->AppError.decode)
243 | }
244 |
245 | let currentUser = async (): result => {
246 | let url = Endpoints.user()
247 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
248 |
249 | let res = await fetch(
250 | url,
251 | {
252 | headers: headers,
253 | },
254 | )
255 |
256 | let res = await parseBodyAsJson(res)
257 | let res = await extractTextError(res)
258 |
259 | res->Result.flatMap(json => json->Shape.User.decode->AppError.decode)
260 | }
261 |
262 | let updateUser = async (~user: Shape.User.t, ~password: string, ()): result<
263 | Shape.User.t,
264 | AppError.t,
265 | > => {
266 | let url = Endpoints.user()
267 |
268 | let user =
269 | list{
270 | list{("email", Js.Json.string(user.email))},
271 | list{("bio", Js.Json.string(user.bio->Option.getWithDefault("")))},
272 | list{("image", Js.Json.string(user.image->Option.getWithDefault("")))},
273 | list{("username", Js.Json.string(user.username))},
274 | if password == "" {
275 | list{}
276 | } else {
277 | list{("password", Js.Json.string(password))}
278 | },
279 | }
280 | ->List.flatten
281 | ->Js.Dict.fromList
282 | ->Js.Json.object_
283 |
284 | let method = #PUT
285 |
286 | let headers =
287 | addJwtAuthorization()->Array.concat(addJsonContentType())->Headers.Init.array->Headers.make
288 |
289 | let body = list{("user", user)}->Js.Dict.fromList->Js.Json.object_->Js.Json.stringify->Body.string
290 |
291 | let res = await fetch(
292 | url,
293 | {
294 | method,
295 | headers,
296 | body,
297 | },
298 | )
299 | let res = await parseBodyAsJson(res)
300 | let res = await extractJsonError(res)
301 |
302 | res->Result.flatMap(json => json->Shape.User.decode->AppError.decode)
303 | }
304 |
305 | let followUser = async (~action: Action.follow, ()): result => {
306 | let url = Endpoints.Profiles.follow(
307 | ~username=switch action {
308 | | Follow(username) | Unfollow(username) => username
309 | },
310 | (),
311 | )
312 |
313 | let method = switch action {
314 | | Follow(_username) => #POST
315 | | Unfollow(_username) => #DELETE
316 | }
317 |
318 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
319 |
320 | let res = await fetch(
321 | url,
322 | {
323 | method,
324 | headers,
325 | },
326 | )
327 |
328 | let res = await parseBodyAsJson(res)
329 | let res = await extractTextError(res)
330 |
331 | res->Result.flatMap(json => {
332 | try {
333 | json
334 | ->Js.Json.decodeObject
335 | ->Option.getExn
336 | ->Js.Dict.get("profile")
337 | ->Option.getExn
338 | ->Shape.Author.decode
339 | ->AppError.decode
340 | } catch {
341 | | _ => AppError.decode(Result.Error("API.followUser: failed to decode json"))
342 | }
343 | })
344 | }
345 |
346 | let getComments = async (~slug: string, ()): result, AppError.t> => {
347 | let url = Endpoints.Articles.comments(~slug, ())
348 |
349 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
350 |
351 | let res = await fetch(
352 | url,
353 | {
354 | headers: headers,
355 | },
356 | )
357 | let res = await parseBodyAsJson(res)
358 | let res = await extractTextError(res)
359 |
360 | res->Result.flatMap(json => json->Shape.Comment.decode->AppError.decode)
361 | }
362 |
363 | let deleteComment = async (~slug: string, ~id: int, ()): result<(string, int), AppError.t> => {
364 | let url = Endpoints.Articles.comment(~slug, ~id, ())
365 | let method = #DELETE
366 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
367 |
368 | let res = await fetch(
369 | url,
370 | {
371 | method,
372 | headers,
373 | },
374 | )
375 |
376 | let res = await parseBodyAsJson(res)
377 | let res = await extractTextError(res)
378 |
379 | res->Result.flatMap(_json => Result.Ok((slug, id)))
380 | }
381 |
382 | let addComment = async (~slug: string, ~body: string, ()): result => {
383 | let url = Endpoints.Articles.comments(~slug, ())
384 |
385 | let method = #POST
386 |
387 | let headers =
388 | addJwtAuthorization()->Array.concat(addJsonContentType())->Headers.Init.array->Headers.make
389 |
390 | let comment = list{("body", Js.Json.string(body))}->Js.Dict.fromList->Js.Json.object_
391 |
392 | let body =
393 | list{("comment", comment)}->Js.Dict.fromList->Js.Json.object_->Js.Json.stringify->Body.string
394 |
395 | let res = await fetch(
396 | url,
397 | {
398 | method,
399 | headers,
400 | body,
401 | },
402 | )
403 |
404 | let res = await parseBodyAsJson(res)
405 | let res = await extractTextError(res)
406 |
407 | res->Result.flatMap(json => {
408 | try {
409 | json
410 | ->Js.Json.decodeObject
411 | ->Option.getExn
412 | ->Js.Dict.get("comment")
413 | ->Option.getExn
414 | ->Shape.Comment.decodeComment
415 | ->AppError.decode
416 | } catch {
417 | | _ => AppError.decode(Result.Error("API.addComment: failed to decode json"))
418 | }
419 | })
420 | }
421 |
422 | let getProfile = async (~username: string, ()): result => {
423 | let url = Endpoints.Profiles.profile(~username, ())
424 |
425 | let headers = addJwtAuthorization()->Headers.Init.array->Headers.make
426 |
427 | let res = await fetch(
428 | url,
429 | {
430 | headers: headers,
431 | },
432 | )
433 |
434 | let res = await parseBodyAsJson(res)
435 | let res = await extractTextError(res)
436 |
437 | res->Result.flatMap(json => {
438 | try {
439 | json
440 | ->Js.Json.decodeObject
441 | ->Option.getExn
442 | ->Js.Dict.get("profile")
443 | ->Option.getExn
444 | ->Shape.Author.decode
445 | ->AppError.decode
446 | } catch {
447 | | _ => AppError.decode(Result.Error("API.getProfile: failed to decode json"))
448 | }
449 | })
450 | }
451 |
452 | let login = async (~email: string, ~password: string, ()): result => {
453 | let url = Endpoints.Users.login()
454 |
455 | let method = #POST
456 |
457 | let headers = addJsonContentType()->Headers.Init.array->Headers.make
458 |
459 | let user =
460 | list{("email", Js.Json.string(email)), ("password", Js.Json.string(password))}
461 | ->Js.Dict.fromList
462 | ->Js.Json.object_
463 |
464 | let body = list{("user", user)}->Js.Dict.fromList->Js.Json.object_->Js.Json.stringify->Body.string
465 |
466 | let res = await fetch(
467 | url,
468 | {
469 | method,
470 | headers,
471 | body,
472 | },
473 | )
474 | let res = await parseBodyAsJson(res)
475 | let res = await extractJsonError(res)
476 |
477 | res->Result.flatMap(json => json->Shape.User.decode->AppError.decode)
478 | }
479 |
480 | let register = async (~username: string, ~email: string, ~password: string, ()): result<
481 | Shape.User.t,
482 | AppError.t,
483 | > => {
484 | let url = Endpoints.Users.root()
485 |
486 | let method = #POST
487 |
488 | let headers = addJsonContentType()->Headers.Init.array->Headers.make
489 |
490 | let user =
491 | list{
492 | ("email", Js.Json.string(email)),
493 | ("password", Js.Json.string(password)),
494 | ("username", Js.Json.string(username)),
495 | }
496 | ->Js.Dict.fromList
497 | ->Js.Json.object_
498 |
499 | let body = list{("user", user)}->Js.Dict.fromList->Js.Json.object_->Js.Json.stringify->Body.string
500 |
501 | let res = await fetch(
502 | url,
503 | {
504 | method,
505 | headers,
506 | body,
507 | },
508 | )
509 |
510 | let res = await parseBodyAsJson(res)
511 | let res = await extractJsonError(res)
512 |
513 | res->Result.flatMap(json => json->Shape.User.decode->AppError.decode)
514 | }
515 |
--------------------------------------------------------------------------------
/src/shared/AppError.res:
--------------------------------------------------------------------------------
1 | type t =
2 | | Fetch((int, string, [#text(string) | #json(Js.Json.t)]))
3 | | Decode(string)
4 |
5 | let fetch: ((int, string, [#text(string) | #json(Js.Json.t)])) => t = e => Fetch(e)
6 |
7 | let decode = (result: result<'a, string>): result<'a, t> =>
8 | switch result {
9 | | Ok(_ok) as ok => ok
10 | | Error(err) => Error(Decode(err))
11 | }
12 |
--------------------------------------------------------------------------------
/src/shared/AsyncData.res:
--------------------------------------------------------------------------------
1 | // origin from https://github.com/reazen/relude/blob/master/src/Relude_AsyncData.re
2 | type t<'a> =
3 | | Init
4 | | Loading
5 | | Reloading('a)
6 | | Complete('a)
7 |
8 | let init = Init
9 |
10 | let reloading = v => Reloading(v)
11 |
12 | let isBusy = v =>
13 | switch v {
14 | | Init => false
15 | | Loading => true
16 | | Reloading(_) => true
17 | | Complete(_) => false
18 | }
19 |
20 | let isComplete = v =>
21 | switch v {
22 | | Init => false
23 | | Loading => false
24 | | Reloading(_) => false
25 | | Complete(_) => true
26 | }
27 |
28 | let toBusy = v =>
29 | switch v {
30 | | Init => Loading
31 | | Loading as a => a
32 | | Reloading(_) as a => a
33 | | Complete(a) => Reloading(a)
34 | }
35 |
36 | let complete = v => Complete(v)
37 |
38 | let getValue = v =>
39 | switch v {
40 | | Init => None
41 | | Loading => None
42 | | Reloading(a) => Some(a)
43 | | Complete(a) => Some(a)
44 | }
45 |
46 | let map = (v, fn) =>
47 | switch v {
48 | | Init => Init
49 | | Loading => Loading
50 | | Reloading(a) => Reloading(fn(a))
51 | | Complete(a) => Complete(fn(a))
52 | }
53 |
54 | let tapComplete = (v, fn) =>
55 | switch v {
56 | | Init => v
57 | | Loading => v
58 | | Reloading(_) => v
59 | | Complete(a) =>
60 | fn(a)
61 | v
62 | }
63 |
64 | let toIdle = v =>
65 | switch v {
66 | | Init as a => a
67 | | Loading => Init
68 | | Reloading(a) => Complete(a)
69 | | Complete(_) as a => a
70 | }
71 |
72 | let debug = v =>
73 | switch v {
74 | | Init => "Init"
75 | | Loading => "Loading"
76 | | Reloading(_) => "Reloading(_)"
77 | | Complete(_) => "Complete(_)"
78 | }
79 |
--------------------------------------------------------------------------------
/src/shared/AsyncResult.res:
--------------------------------------------------------------------------------
1 | // origin from https://github.com/reazen/relude/blob/master/src/Relude_AsyncResult.re
2 | type t<'a, 'e> = AsyncData.t>
3 |
4 | let init = AsyncData.init
5 | let toBusy = AsyncData.toBusy
6 | let completeOk = a => AsyncData.complete(Ok(a))
7 | let completeError = e => AsyncData.complete(Error(e))
8 | let reloadingOk = a => AsyncData.reloading(Ok(a))
9 |
10 | let isBusy = AsyncData.isBusy
11 |
12 | let getOk = (v: t<'a, 'e>): option<'a> =>
13 | switch v {
14 | | Init => None
15 | | Loading => None
16 | | Reloading(Error(_)) => None
17 | | Reloading(Ok(a)) => Some(a)
18 | | Complete(Error(_)) => None
19 | | Complete(Ok(a)) => Some(a)
20 | }
21 |
22 | let map = (v: t<'a, 'e>, fn): t<'b, 'e> =>
23 | switch v {
24 | | Init => Init
25 | | Loading => Loading
26 | | Reloading(Ok(a)) => reloadingOk(fn(a))
27 | | Reloading(Error(_)) as r => r
28 | | Complete(Ok(a)) => completeOk(fn(a))
29 | | Complete(Error(_)) as r => r
30 | }
31 |
32 | let toIdle = AsyncData.toIdle
33 |
34 | let debug = AsyncData.debug
35 |
--------------------------------------------------------------------------------
/src/shared/Constant.res:
--------------------------------------------------------------------------------
1 | module Duration = {
2 | let secondInMs = 1000.
3 | let minuteInMs = 60. *. secondInMs
4 | let hourInMs = 60. *. minuteInMs
5 | let dayInMs = 24. *. hourInMs
6 | let monthInMs = 30. *. dayInMs
7 | }
8 |
9 | module Auth = {
10 | let tokenCookieName = "jwtToken"
11 | }
12 |
--------------------------------------------------------------------------------
/src/shared/Endpoints.res:
--------------------------------------------------------------------------------
1 | @scope(("window", "app")) @val external backend: string = "backend"
2 |
3 | module Articles = {
4 | let root: (
5 | ~limit: int=?,
6 | ~offset: int=?,
7 | ~tag: string=?,
8 | ~author: string=?,
9 | ~favorited: string=?,
10 | unit,
11 | ) => string = (~limit=10, ~offset=0, ~tag=?, ~author=?, ~favorited=?, ()) => {
12 | let limit = limit->Int.toString
13 | let offset = offset->Int.toString
14 | let tag = tag->Option.map(tag' => "&tag=" ++ tag')->Option.getWithDefault("")
15 | let author = author->Option.map(author' => "&author=" ++ author')->Option.getWithDefault("")
16 | let favorited =
17 | favorited->Option.map(favorited' => "&favorited=" ++ favorited')->Option.getWithDefault("")
18 |
19 | `${backend}/api/articles?limit=${limit}&offset=${offset}${tag}${author}${favorited}`
20 | }
21 |
22 | let article: (~slug: string, unit) => string = (~slug, ()) => `${backend}/api/articles/${slug}`
23 |
24 | let favorite: (~slug: string, unit) => string = (~slug, ()) =>
25 | `${backend}/api/articles/${slug}/favorite`
26 |
27 | let feed: (~limit: int=?, ~offset: int=?, unit) => string = (~limit=10, ~offset=0, ()) => {
28 | let limit = limit->Int.toString
29 | let offset = offset->Int.toString
30 |
31 | `${backend}/api/articles/feed?limit=${limit}&offset=${offset}`
32 | }
33 |
34 | let comments: (~slug: string, unit) => string = (~slug: string, ()) =>
35 | `${backend}/api/articles/${slug}/comments`
36 |
37 | let comment: (~slug: string, ~id: int, unit) => string = (~slug, ~id, ()) => {
38 | let id = id->Int.toString
39 |
40 | `${backend}/api/articles/${slug}/comments/${id}`
41 | }
42 | }
43 |
44 | module Profiles = {
45 | let profile: (~username: string, unit) => string = (~username, ()) =>
46 | `${backend}/api/profiles/${username}`
47 |
48 | let follow: (~username: string, unit) => string = (~username, ()) =>
49 | `${backend}/api/profiles/${username}/follow`
50 | }
51 |
52 | module Users = {
53 | let root = () => `${backend}/api/users`
54 | let login = () => `${backend}/api/users/login`
55 | }
56 |
57 | let tags = () => `${backend}/api/tags`
58 |
59 | let user = () => `${backend}/api/user`
60 |
--------------------------------------------------------------------------------
/src/shared/Hook.res:
--------------------------------------------------------------------------------
1 | type asyncArticles = AsyncResult.t
2 | type asyncTags = AsyncResult.t
3 | type asyncData = AsyncData.t>
4 | type asyncArticleEditor = AsyncResult.t<
5 | (Shape.Article.t, string, option),
6 | AppError.t,
7 | >
8 | type asyncArticle = AsyncResult.t
9 | type asyncComment = AsyncResult.t, AppError.t>
10 | type asyncAuthor = AsyncResult.t
11 |
12 | let useArticles = (~feedType: Shape.FeedType.t): (
13 | asyncArticles,
14 | (asyncArticles => asyncArticles) => unit,
15 | ) => {
16 | let (data, setData) = React.useState(() => AsyncResult.init)
17 |
18 | React.useEffect2(() => {
19 | setData(prev => prev->AsyncResult.toBusy)
20 |
21 | switch feedType {
22 | | Tag(tag, limit, offset) => API.listArticles(~limit, ~offset, ~tag=?Some(tag), ())
23 | | Global(limit, offset) => API.listArticles(~limit, ~offset, ())
24 | | Personal(limit, offset) => API.feedArticles(~limit, ~offset, ())
25 | }
26 | ->Promise.then(data =>
27 | setData(
28 | _prev =>
29 | switch data {
30 | | Ok(ok) => AsyncResult.completeOk(ok)
31 | | Error(error) => AsyncResult.completeError(error)
32 | },
33 | )->Promise.resolve
34 | )
35 | ->ignore
36 |
37 | None
38 | }, (feedType, setData))
39 |
40 | (data, setData)
41 | }
42 |
43 | let useArticlesInProfile: (
44 | ~viewMode: Shape.Profile.viewMode,
45 | ) => (asyncArticles, (asyncArticles => asyncArticles) => unit) = (~viewMode) => {
46 | let (data, setData) = React.useState(() => AsyncResult.init)
47 |
48 | React.useEffect2(() => {
49 | setData(prev => prev->AsyncResult.toBusy)
50 |
51 | switch viewMode {
52 | | Author(author, limit, offset) => API.listArticles(~author, ~limit, ~offset, ())
53 | | Favorited(favorited, limit, offset) => API.listArticles(~favorited, ~limit, ~offset, ())
54 | }
55 | ->Promise.then(data =>
56 | setData(
57 | _prev =>
58 | switch data {
59 | | Ok(ok) => AsyncResult.completeOk(ok)
60 | | Error(error) => AsyncResult.completeError(error)
61 | },
62 | )->Promise.resolve
63 | )
64 | ->ignore
65 |
66 | None
67 | }, (viewMode, setData))
68 |
69 | (data, setData)
70 | }
71 |
72 | let useTags: unit => asyncTags = () => {
73 | let (data, setData) = React.useState(() => AsyncResult.init)
74 |
75 | React.useEffect0(() => {
76 | setData(prev => prev->AsyncResult.getOk->Option.getWithDefault([])->AsyncResult.reloadingOk)
77 |
78 | API.tags()
79 | ->Promise.then(data =>
80 | setData(
81 | _prev =>
82 | switch data {
83 | | Ok(ok) => ok->AsyncResult.completeOk
84 | | Error(error) => AsyncResult.completeError(error)
85 | },
86 | )->Promise.resolve
87 | )
88 | ->ignore
89 |
90 | None
91 | })
92 |
93 | data
94 | }
95 |
96 | let useCurrentUser: unit => (asyncData, (asyncData => asyncData) => unit) = () => {
97 | let (data, setData) = React.useState(() => AsyncData.init)
98 |
99 | React.useEffect0(() => {
100 | setData(prev => prev->AsyncData.toBusy)
101 |
102 | API.currentUser()
103 | ->Promise.then(data =>
104 | setData(
105 | _prev =>
106 | switch data {
107 | | Ok(data') => Some(data')->AsyncData.complete
108 | | Error(_error) => None->AsyncData.complete
109 | },
110 | )->Promise.resolve
111 | )
112 | ->Promise.catch(_error => setData(_prev => None->AsyncData.complete)->Promise.resolve)
113 | ->ignore
114 |
115 | None
116 | })
117 |
118 | (data, setData)
119 | }
120 |
121 | let useArticle = (~slug: string): (
122 | asyncArticleEditor,
123 | (asyncArticleEditor => asyncArticleEditor) => unit,
124 | ) => {
125 | let (data, setData) = React.useState(() => AsyncResult.init)
126 |
127 | React.useEffect1(() => {
128 | setData(AsyncResult.toBusy)
129 |
130 | API.article(~action=Read(slug), ())
131 | ->Promise.then(data =>
132 | setData(
133 | _prev =>
134 | switch data {
135 | | Ok(ok: Shape.Article.t) =>
136 | AsyncResult.completeOk((ok, ok.tagList->Array.joinWith(","), None))
137 | | Error(error) => AsyncResult.completeError(error)
138 | },
139 | )->Promise.resolve
140 | )
141 | ->ignore
142 |
143 | None
144 | }, [slug])
145 |
146 | (data, setData)
147 | }
148 |
149 | let useComments: (
150 | ~slug: string,
151 | ) => (
152 | asyncComment,
153 | Belt.Set.Int.t,
154 | (~slug: string, ~id: int) => unit,
155 | (asyncComment => asyncComment) => unit,
156 | ) = (~slug) => {
157 | let (data, setData) = React.useState(() => AsyncResult.init)
158 | let (busy, setBusy) = React.useState(() => Belt.Set.Int.empty)
159 |
160 | React.useEffect2(() => {
161 | setData(prev => prev->AsyncResult.toBusy)
162 | setBusy(_prev => Belt.Set.Int.empty)
163 |
164 | API.getComments(~slug, ())
165 | ->Promise.then(data =>
166 | setData(
167 | _prev =>
168 | switch data {
169 | | Ok(ok) => AsyncResult.completeOk(ok)
170 | | Error(error) => AsyncResult.completeError(error)
171 | },
172 | )->Promise.resolve
173 | )
174 | ->ignore
175 |
176 | None
177 | }, (slug, setData))
178 |
179 | let deleteComment = (~slug, ~id) => {
180 | setBusy(prev => prev->Belt.Set.Int.add(_, id))
181 |
182 | API.deleteComment(~slug, ~id, ())
183 | ->Promise.then(resp => {
184 | setBusy(prev => prev->Belt.Set.Int.remove(_, id))
185 |
186 | switch resp {
187 | | Ok((_slug, id)) =>
188 | setData(prev =>
189 | prev->AsyncResult.map(
190 | comments => comments->Array.filter((comment: Shape.Comment.t) => comment.id != id),
191 | )
192 | )
193 | | Error(_error) => ignore()
194 | }
195 |
196 | ignore()->Promise.resolve
197 | })
198 | ->ignore
199 | }
200 |
201 | (data, busy, deleteComment, setData)
202 | }
203 |
204 | let useFollow: (
205 | ~article: asyncArticle,
206 | ~user: option,
207 | ) => (AsyncData.t<(string, bool)>, Link.onClickAction) = (~article, ~user) => {
208 | let (state, setState) = React.useState(() => AsyncData.init)
209 |
210 | let follow = switch state {
211 | | Init =>
212 | article
213 | ->AsyncResult.getOk
214 | ->Option.map((ok: Shape.Article.t) =>
215 | AsyncData.complete((ok.author.username, ok.author.following->Option.getWithDefault(false)))
216 | )
217 | ->Option.getWithDefault(AsyncData.complete(("", false)))
218 | | Loading as orig | Reloading(_) as orig | Complete(_) as orig => orig
219 | }
220 |
221 | let sendRequest = () => {
222 | let username =
223 | follow
224 | ->AsyncData.getValue
225 | ->Option.map(((username, _following)) => username)
226 | ->Option.getWithDefault("")
227 |
228 | let action =
229 | follow
230 | ->AsyncData.getValue
231 | ->Option.flatMap(((_username, following)) =>
232 | following ? Some(API.Action.Unfollow(username)) : None
233 | )
234 | ->Option.getWithDefault(API.Action.Follow(username))
235 |
236 | setState(_prev => follow->AsyncData.toBusy)
237 |
238 | API.followUser(~action, ())
239 | ->Promise.then(data =>
240 | setState(_prev =>
241 | switch data {
242 | | Ok(ok: Shape.Author.t) =>
243 | AsyncData.complete((ok.username, ok.following->Option.getWithDefault(false)))
244 | | Error(_error) => AsyncData.complete(("", false))
245 | }
246 | )->Promise.resolve
247 | )
248 | ->Promise.catch(_error => setState(_prev => AsyncData.complete(("", false)))->Promise.resolve)
249 | ->ignore
250 | }
251 |
252 | let onClick = switch user {
253 | | Some(_user) => Link.CustomFn(() => sendRequest())
254 | | None => Location(Link.register)
255 | }
256 |
257 | (follow, onClick)
258 | }
259 |
260 | let useFollowInProfile: (
261 | ~profile: asyncAuthor,
262 | ~user: option,
263 | ) => (AsyncData.t<(string, bool)>, Link.onClickAction) = (~profile, ~user) => {
264 | let (state, setState) = React.useState(() => AsyncData.init)
265 |
266 | let follow = switch state {
267 | | Init =>
268 | profile
269 | ->AsyncResult.getOk
270 | ->Option.map((ok: Shape.Author.t) =>
271 | AsyncData.complete((ok.username, ok.following->Option.getWithDefault(false)))
272 | )
273 | ->Option.getWithDefault(AsyncData.complete(("", false)))
274 | | Loading as orig | Reloading(_) as orig | Complete(_) as orig => orig
275 | }
276 |
277 | let sendRequest = () => {
278 | let username =
279 | follow
280 | ->AsyncData.getValue
281 | ->Option.map(((username, _following)) => username)
282 | ->Option.getWithDefault("")
283 |
284 | let action =
285 | follow
286 | ->AsyncData.getValue
287 | ->Option.flatMap(((_username, following)) =>
288 | following ? Some(API.Action.Unfollow(username)) : None
289 | )
290 | ->Option.getWithDefault(API.Action.Follow(username))
291 |
292 | setState(_prev => follow->AsyncData.toBusy)
293 |
294 | API.followUser(~action, ())
295 | ->Promise.then(data =>
296 | setState(_prev =>
297 | switch data {
298 | | Ok(ok: Shape.Author.t) =>
299 | AsyncData.complete((ok.username, ok.following->Option.getWithDefault(false)))
300 | | Error(_error) => AsyncData.complete(("", false))
301 | }
302 | )->Promise.resolve
303 | )
304 | ->Promise.catch(_error => setState(_prev => AsyncData.complete(("", false)))->Promise.resolve)
305 | ->ignore
306 | }
307 |
308 | let onClick = switch user {
309 | | Some(_user) => Link.CustomFn(() => sendRequest())
310 | | None => Location(Link.register)
311 | }
312 |
313 | (follow, onClick)
314 | }
315 |
316 | let useFavorite = (~article: asyncArticle, ~user: option): (
317 | AsyncData.t<(bool, int, string)>,
318 | Link.onClickAction,
319 | ) => {
320 | let (state, setState) = React.useState(() => AsyncData.init)
321 |
322 | let favorite = switch state {
323 | | Init =>
324 | article
325 | ->AsyncResult.getOk
326 | ->Option.map((ok: Shape.Article.t) =>
327 | AsyncData.complete((ok.favorited, ok.favoritesCount, ok.slug))
328 | )
329 | ->Option.getWithDefault(AsyncData.complete((false, 0, "")))
330 | | Loading as orig | Reloading(_) as orig | Complete(_) as orig => orig
331 | }
332 |
333 | let sendRequest = () => {
334 | let (favorited, _favoritesCount, slug) =
335 | favorite->AsyncData.getValue->Option.getWithDefault((false, 0, ""))
336 |
337 | let action = favorited ? API.Action.Unfavorite(slug) : API.Action.Favorite(slug)
338 |
339 | setState(_prev => favorite->AsyncData.toBusy)
340 |
341 | API.favoriteArticle(~action, ())
342 | ->Promise.then(data =>
343 | setState(_prev =>
344 | switch data {
345 | | Ok(ok: Shape.Article.t) => AsyncData.complete((ok.favorited, ok.favoritesCount, ok.slug))
346 | | Error(_error) => AsyncData.complete((false, 0, ""))
347 | }
348 | )->Promise.resolve
349 | )
350 | ->Promise.catch(_error =>
351 | setState(_prev => AsyncData.complete((false, 0, "")))->Promise.resolve
352 | )
353 | ->ignore
354 | }
355 |
356 | let onClick = switch user {
357 | | Some(_user) => Link.CustomFn(() => sendRequest())
358 | | None => Location(Link.register)
359 | }
360 |
361 | (favorite, onClick)
362 | }
363 |
364 | let useDeleteArticle: (
365 | ~article: asyncArticle,
366 | ~user: option,
367 | ) => (bool, Link.onClickAction) = (~article, ~user) => {
368 | let (state, setState) = React.useState(() => false)
369 |
370 | let sendRequest = () => {
371 | let slug =
372 | article
373 | ->AsyncResult.getOk
374 | ->Option.map((ok: Shape.Article.t) => ok.slug)
375 | ->Option.getWithDefault("")
376 |
377 | setState(_prev => true)
378 |
379 | API.article(~action=Delete(slug), ())
380 | ->Promise.then(_data => {
381 | setState(_prev => false)
382 | Link.push(Link.home)
383 | ignore()->Promise.resolve
384 | })
385 | ->Promise.catch(_error => setState(_prev => false)->Promise.resolve)
386 | ->ignore
387 | }
388 |
389 | let onClick = switch (user, state) {
390 | | (Some(_user), false) =>
391 | Link.CustomFn(
392 | () =>
393 | if (
394 | Webapi.Dom.Window.confirm(
395 | Webapi.Dom.window,
396 | "Are you sure you want to delete this article?",
397 | )
398 | ) {
399 | sendRequest()
400 | } else {
401 | ignore()
402 | },
403 | )
404 | | (Some(_), true) | (None, true | false) => Link.CustomFn(ignore)
405 | }
406 |
407 | (state, onClick)
408 | }
409 |
410 | let useToggleFavorite: (
411 | ~setArticles: (asyncArticles => asyncArticles) => unit,
412 | ~user: option,
413 | ) => (Belt.Set.String.t, (~action: API.Action.favorite) => unit) = (~setArticles, ~user) => {
414 | let (busy, setBusy) = React.useState(() => Belt.Set.String.empty)
415 |
416 | let sendRequest = (~action) => {
417 | let slug = switch (action: API.Action.favorite) {
418 | | Favorite(slug) | Unfavorite(slug) => slug
419 | }
420 |
421 | setBusy(prev => prev->Belt.Set.String.add(_, slug))
422 |
423 | API.favoriteArticle(~action, ())
424 | ->Promise.then(data => {
425 | setBusy(prev => prev->Belt.Set.String.remove(_, slug))
426 |
427 | switch data {
428 | | Ok(_) =>
429 | setArticles(prev =>
430 | prev->AsyncResult.map(
431 | (articles: Shape.Articles.t) => {
432 | ...articles,
433 | articles: articles.articles->Array.map(
434 | (article: Shape.Article.t) =>
435 | if article.slug == slug {
436 | {
437 | ...article,
438 | favorited: switch action {
439 | | Favorite(_) => true
440 | | Unfavorite(_) => false
441 | },
442 | favoritesCount: switch action {
443 | | Favorite(_) => article.favoritesCount + 1
444 | | Unfavorite(_) => article.favoritesCount - 1
445 | },
446 | }
447 | } else {
448 | article
449 | },
450 | ),
451 | },
452 | )
453 | )
454 | | Error(_error) => ignore()
455 | }
456 |
457 | ignore()->Promise.resolve
458 | })
459 | ->Promise.catch(_error =>
460 | setBusy(prev => prev->Belt.Set.String.remove(_, slug))->Promise.resolve
461 | )
462 | ->ignore
463 | }
464 |
465 | let onToggle = (~action) =>
466 | switch user {
467 | | Some(_) => sendRequest(~action)
468 | | None => Link.push(Link.register)
469 | }
470 |
471 | (busy, onToggle)
472 | }
473 |
474 | let useProfile: (~username: string) => asyncAuthor = (~username) => {
475 | let (data, setData) = React.useState(() => AsyncResult.init)
476 |
477 | React.useEffect2(() => {
478 | setData(prev => prev->AsyncResult.toBusy)
479 |
480 | API.getProfile(~username, ())
481 | ->Promise.then(data =>
482 | setData(
483 | _prev =>
484 | switch data {
485 | | Ok(ok) => AsyncResult.completeOk(ok)
486 | | Error(error) => AsyncResult.completeError(error)
487 | },
488 | )->Promise.resolve
489 | )
490 | ->ignore
491 |
492 | None
493 | }, (username, setData))
494 |
495 | data
496 | }
497 |
498 | let useViewMode: (~route: Shape.Profile.viewMode) => (Shape.Profile.viewMode, int => unit) = (
499 | ~route,
500 | ) => {
501 | let (viewMode, setViewMode) = React.useState(() => None)
502 | let finalViewMode = viewMode->Option.getWithDefault(route)
503 |
504 | React.useEffect2(() => {
505 | setViewMode(_prev => None)
506 | None
507 | }, (route, setViewMode))
508 |
509 | let changeOffset = offset =>
510 | setViewMode(_prev => Some(
511 | switch finalViewMode {
512 | | Author(username, limit, _offset) => Author(username, limit, offset)
513 | | Favorited(username, limit, _offset) => Favorited(username, limit, offset)
514 | },
515 | ))
516 |
517 | (finalViewMode, changeOffset)
518 | }
519 |
--------------------------------------------------------------------------------
/src/shared/Markdown.res:
--------------------------------------------------------------------------------
1 | module DomPurify = {
2 | type t
3 | @module("dompurify") external make: Dom.window => t = "default"
4 | @send external sanitize: (t, string) => string = "sanitize"
5 | }
6 |
7 | module Marked = {
8 | type options = {
9 | mangle: bool,
10 | headerIds: bool,
11 | }
12 | @module("marked") @scope("marked") external parse: string => string = "parse"
13 | @module("marked") external use: options => unit = "use"
14 | }
15 |
16 | Marked.use({mangle: false, headerIds: false})
17 |
18 | let dompurify = DomPurify.make(Webapi.Dom.window)
19 |
20 | let toHTML = (markdown: string): string => {
21 | dompurify->DomPurify.sanitize(Marked.parse(markdown))
22 | }
23 |
--------------------------------------------------------------------------------
/src/shared/Markdown.resi:
--------------------------------------------------------------------------------
1 | let toHTML: string => string
2 |
--------------------------------------------------------------------------------
/src/shared/Shape.res:
--------------------------------------------------------------------------------
1 | module Json = Js.Json
2 | module Dict = Js.Dict
3 |
4 | type decodeError = string
5 |
6 | module Profile = {
7 | type username = string
8 | type limit = int
9 | type offset = int
10 | type viewMode =
11 | | Author(username, limit, offset)
12 | | Favorited(username, limit, offset)
13 | }
14 |
15 | module FeedType = {
16 | type tag = string
17 | type limit = int
18 | type offset = int
19 | type t =
20 | | Tag(tag, limit, offset)
21 | | Global(limit, offset)
22 | | Personal(limit, offset)
23 | }
24 |
25 | module Author = {
26 | type t = {
27 | username: string,
28 | bio: option,
29 | image: string,
30 | following: option,
31 | }
32 |
33 | let decode = (json: Json.t): Result.t => {
34 | try {
35 | let obj = json->Json.decodeObject->Option.getExn
36 | let username = obj->Dict.get("username")->Option.flatMap(Json.decodeString)->Option.getExn
37 | let bio = obj->Dict.get("bio")->Option.flatMap(Json.decodeString)
38 | let image = obj->Dict.get("image")->Option.flatMap(Json.decodeString)->Option.getExn
39 | let following = obj->Dict.get("following")->Option.flatMap(Json.decodeBoolean)
40 |
41 | Result.Ok({
42 | username,
43 | bio,
44 | image,
45 | following,
46 | })
47 | } catch {
48 | | _ => Error("Shape.Author: failed to decode json")
49 | }
50 | }
51 | }
52 |
53 | module Article = {
54 | type t = {
55 | slug: string,
56 | title: string,
57 | description: string,
58 | body: string,
59 | tagList: array,
60 | createdAt: Js.Date.t,
61 | updatedAt: Js.Date.t,
62 | favorited: bool,
63 | favoritesCount: int,
64 | author: Author.t,
65 | }
66 |
67 | let decode = (json: Json.t): Result.t => {
68 | try {
69 | let obj = json->Json.decodeObject->Option.getExn
70 | let slug = obj->Dict.get("slug")->Option.flatMap(Json.decodeString)->Option.getExn
71 | let title = obj->Dict.get("title")->Option.flatMap(Json.decodeString)->Option.getExn
72 | let description =
73 | obj->Dict.get("description")->Option.flatMap(Json.decodeString)->Option.getExn
74 | let body = obj->Dict.get("body")->Option.flatMap(Json.decodeString)->Option.getExn
75 | let tagList =
76 | obj
77 | ->Dict.get("tagList")
78 | ->Option.flatMap(Json.decodeArray)
79 | ->Option.flatMap(tagList => Some(tagList->Array.filterMap(Json.decodeString)))
80 | ->Option.getExn
81 | let createdAt =
82 | obj
83 | ->Dict.get("createdAt")
84 | ->Option.flatMap(Json.decodeString)
85 | ->Option.getExn
86 | ->Js.Date.fromString
87 | let updatedAt =
88 | obj
89 | ->Dict.get("updatedAt")
90 | ->Option.flatMap(Json.decodeString)
91 | ->Option.getExn
92 | ->Js.Date.fromString
93 | let favorited = obj->Dict.get("favorited")->Option.flatMap(Json.decodeBoolean)->Option.getExn
94 | let favoritesCount =
95 | obj
96 | ->Dict.get("favoritesCount")
97 | ->Option.flatMap(Json.decodeNumber)
98 | ->Option.getExn
99 | ->int_of_float
100 | let author =
101 | obj
102 | ->Dict.get("author")
103 | ->Option.flatMap(author => {
104 | switch author->Author.decode {
105 | | Ok(ok) => Some(ok)
106 | | Error(_err) => None
107 | }
108 | })
109 | ->Option.getExn
110 |
111 | Result.Ok({
112 | slug,
113 | title,
114 | description,
115 | body,
116 | tagList,
117 | createdAt,
118 | updatedAt,
119 | favorited,
120 | favoritesCount,
121 | author,
122 | })
123 | } catch {
124 | | _ => Error("Shape.Article: failed to decode json")
125 | }
126 | }
127 | }
128 |
129 | module Articles = {
130 | type t = {
131 | articles: array,
132 | articlesCount: int,
133 | }
134 |
135 | let decode = (json: Json.t): Result.t => {
136 | try {
137 | let obj = json->Json.decodeObject->Option.getExn
138 | let articles =
139 | obj
140 | ->Dict.get("articles")
141 | ->Option.flatMap(Json.decodeArray)
142 | ->Option.flatMap(articles => {
143 | articles
144 | ->Array.filterMap(article =>
145 | switch article->Article.decode {
146 | | Ok(ok) => Some(ok)
147 | | Error(_err) => None
148 | }
149 | )
150 | ->Some
151 | })
152 | ->Option.getExn
153 | let articlesCount =
154 | obj
155 | ->Dict.get("articlesCount")
156 | ->Option.flatMap(Json.decodeNumber)
157 | ->Option.map(int_of_float)
158 | ->Option.getExn
159 |
160 | Result.Ok({
161 | articles,
162 | articlesCount,
163 | })
164 | } catch {
165 | | _ => Error("Shape.Article: failed to decode json")
166 | }
167 | }
168 | }
169 |
170 | module Tags = {
171 | type t = array
172 |
173 | let decode = (json: Json.t): Result.t => {
174 | try {
175 | let obj = json->Json.decodeObject->Option.getExn
176 | let tags =
177 | obj
178 | ->Dict.get("tags")
179 | ->Option.flatMap(Json.decodeArray)
180 | ->Option.map(tags => tags->Array.filterMap(Json.decodeString))
181 | ->Option.getExn
182 |
183 | Result.Ok(tags)
184 | } catch {
185 | | _ => Error("Shape.Tags: failed to decode json")
186 | }
187 | }
188 | }
189 |
190 | module User = {
191 | type t = {
192 | email: string,
193 | username: string,
194 | bio: option,
195 | image: option,
196 | token: string,
197 | }
198 |
199 | let empty = {
200 | email: "",
201 | username: "",
202 | bio: None,
203 | image: None,
204 | token: "",
205 | }
206 |
207 | let decodeUser = (json: Json.t): Result.t => {
208 | try {
209 | let obj = json->Json.decodeObject->Option.getExn
210 | let email = obj->Dict.get("email")->Option.flatMap(Json.decodeString)->Option.getExn
211 | let username = obj->Dict.get("username")->Option.flatMap(Json.decodeString)->Option.getExn
212 | let bio = obj->Dict.get("bio")->Option.flatMap(Json.decodeString)
213 | let image = obj->Dict.get("image")->Option.flatMap(Json.decodeString)
214 | let token = obj->Dict.get("token")->Option.flatMap(Json.decodeString)->Option.getExn
215 |
216 | Result.Ok({
217 | email,
218 | username,
219 | bio,
220 | image,
221 | token,
222 | })
223 | } catch {
224 | | _ => Error("Shape.User: failed to decode json")
225 | }
226 | }
227 |
228 | let decode = (json: Json.t): Result.t => {
229 | try {
230 | let obj = json->Json.decodeObject->Option.getExn
231 | let user =
232 | obj
233 | ->Dict.get("user")
234 | ->Option.flatMap(user => {
235 | switch user->decodeUser {
236 | | Ok(ok) => Some(ok)
237 | | Error(_err) => None
238 | }
239 | })
240 | ->Option.getExn
241 |
242 | Result.Ok(user)
243 | } catch {
244 | | _ => Error("Shape.User: failed to decode json")
245 | }
246 | }
247 | }
248 |
249 | module CommentUser = {
250 | type t = {
251 | username: string,
252 | bio: option,
253 | image: string,
254 | following: bool,
255 | }
256 |
257 | let decode = (json: Json.t): Result.t => {
258 | try {
259 | let obj = json->Json.decodeObject->Option.getExn
260 | let username = obj->Dict.get("username")->Option.flatMap(Json.decodeString)->Option.getExn
261 | let bio = obj->Dict.get("bio")->Option.flatMap(Json.decodeString)
262 | let image = obj->Dict.get("image")->Option.flatMap(Json.decodeString)->Option.getExn
263 | let following = obj->Dict.get("following")->Option.flatMap(Json.decodeBoolean)->Option.getExn
264 |
265 | Result.Ok({
266 | username,
267 | bio,
268 | image,
269 | following,
270 | })
271 | } catch {
272 | | _ => Error("Shape.CommentUser: failed to decode json")
273 | }
274 | }
275 | }
276 |
277 | module Comment = {
278 | type t = {
279 | id: int,
280 | createdAt: Js.Date.t,
281 | updatedAt: Js.Date.t,
282 | body: string,
283 | author: CommentUser.t,
284 | }
285 |
286 | let decodeComment = (json: Json.t): Result.t => {
287 | try {
288 | let obj = json->Json.decodeObject->Option.getExn
289 | let id =
290 | obj
291 | ->Dict.get("id")
292 | ->Option.flatMap(Json.decodeNumber)
293 | ->Option.map(int_of_float)
294 | ->Option.getExn
295 | let createdAt =
296 | obj
297 | ->Dict.get("createdAt")
298 | ->Option.flatMap(Json.decodeString)
299 | ->Option.map(Js.Date.fromString)
300 | ->Option.getExn
301 | let updatedAt =
302 | obj
303 | ->Dict.get("updatedAt")
304 | ->Option.flatMap(Json.decodeString)
305 | ->Option.map(Js.Date.fromString)
306 | ->Option.getExn
307 | let body = obj->Dict.get("body")->Option.flatMap(Json.decodeString)->Option.getExn
308 | let author =
309 | obj
310 | ->Dict.get("author")
311 | ->Option.flatMap(author => {
312 | switch author->CommentUser.decode {
313 | | Ok(ok) => Some(ok)
314 | | Error(_err) => None
315 | }
316 | })
317 | ->Option.getExn
318 |
319 | Result.Ok({
320 | id,
321 | createdAt,
322 | updatedAt,
323 | body,
324 | author,
325 | })
326 | } catch {
327 | | _ => Error("Shape.Comment: failed to decode json")
328 | }
329 | }
330 |
331 | let decode = (json: Json.t): Result.t, decodeError> => {
332 | try {
333 | let obj = json->Json.decodeObject->Option.getExn
334 | let comments =
335 | obj
336 | ->Dict.get("comments")
337 | ->Option.flatMap(Json.decodeArray)
338 | ->Option.map(comments => {
339 | comments->Array.filterMap(comment => {
340 | switch comment->decodeComment {
341 | | Ok(ok) => Some(ok)
342 | | Error(_err) => None
343 | }
344 | })
345 | })
346 | ->Option.getExn
347 |
348 | Result.Ok(comments)
349 | } catch {
350 | | _ => Error("Shape.Comment: failed to decode json")
351 | }
352 | }
353 | }
354 |
355 | module Settings = {
356 | type t = {
357 | email: option>,
358 | bio: option>,
359 | image: option>,
360 | username: option>,
361 | password: option>,
362 | }
363 |
364 | let decode = (json: Json.t): Result.t => {
365 | try {
366 | let obj = json->Json.decodeObject->Option.getExn
367 | let email = obj->Dict.get("email")->Utils.Json.decodeArrayString
368 | let bio = obj->Dict.get("bio")->Utils.Json.decodeArrayString
369 | let image = obj->Dict.get("image")->Utils.Json.decodeArrayString
370 | let username = obj->Dict.get("username")->Utils.Json.decodeArrayString
371 | let password = obj->Dict.get("password")->Utils.Json.decodeArrayString
372 |
373 | Result.Ok({
374 | email,
375 | bio,
376 | image,
377 | username,
378 | password,
379 | })
380 | } catch {
381 | | _ => Error("Shape.Settings: failed to decode json")
382 | }
383 | }
384 | }
385 |
386 | module Editor = {
387 | type t = {
388 | title: option>,
389 | body: option>,
390 | description: option>,
391 | }
392 |
393 | let decode = (json: Json.t): Result.t => {
394 | try {
395 | let obj = json->Json.decodeObject->Option.getExn
396 | let title = obj->Dict.get("title")->Utils.Json.decodeArrayString
397 | let body = obj->Dict.get("body")->Utils.Json.decodeArrayString
398 | let description = obj->Dict.get("description")->Utils.Json.decodeArrayString
399 |
400 | Result.Ok({
401 | title,
402 | body,
403 | description,
404 | })
405 | } catch {
406 | | _ => Error("Shape.Editor: failed to decode json")
407 | }
408 | }
409 | }
410 |
411 | module Login = {
412 | type t = option>
413 |
414 | let decode = (json: Json.t): Result.t => {
415 | try {
416 | json
417 | ->Json.decodeObject
418 | ->Option.getExn
419 | ->Dict.get("email or password")
420 | ->Utils.Json.decodeArrayString
421 | ->Ok
422 | } catch {
423 | | _ => Error("Shape.Login: failed to decode json")
424 | }
425 | }
426 | }
427 |
428 | module Register = {
429 | type t = {
430 | email: option>,
431 | password: option>,
432 | username: option>,
433 | }
434 |
435 | let decode = (json: Json.t): Result.t => {
436 | try {
437 | let obj = json->Json.decodeObject->Option.getExn
438 | let email = obj->Dict.get("email")->Utils.Json.decodeArrayString
439 | let username = obj->Dict.get("username")->Utils.Json.decodeArrayString
440 | let password = obj->Dict.get("password")->Utils.Json.decodeArrayString
441 |
442 | Result.Ok({
443 | email,
444 | password,
445 | username,
446 | })
447 | } catch {
448 | | _ => Error("Shape.Register: failed to decode json")
449 | }
450 | }
451 | }
452 |
--------------------------------------------------------------------------------
/src/shared/Utils.res:
--------------------------------------------------------------------------------
1 | type cookiePair = (string, option)
2 |
3 | let parseCookies = (): array => {
4 | let cookie =
5 | Webapi.Dom.document
6 | ->Webapi.Dom.Document.asHtmlDocument
7 | ->Option.getExn
8 | ->Webapi.Dom.HtmlDocument.cookie
9 |
10 | cookie
11 | ->String.split(";")
12 | ->Array.map(segment => {
13 | let pair = segment->String.split("=")
14 | let key = pair->Array.get(0)->Option.getExn
15 | let value = pair->Array.get(1)
16 | (key, value)
17 | })
18 | }
19 |
20 | let getCookie = (name: string): option =>
21 | parseCookies()->Array.find(pair => {
22 | let key = fst(pair)
23 | key == name
24 | })
25 |
26 | let setCookieRaw = (
27 | ~key: string,
28 | ~value: option=?,
29 | ~expires: string,
30 | ~path: option=?,
31 | (),
32 | ): unit => {
33 | let htmlDocument = Webapi.Dom.document->Webapi.Dom.Document.asHtmlDocument->Option.getExn
34 |
35 | let value = value->Option.getWithDefault("")
36 | let expires = expires !== "" ? `expires=${expires};` : ""
37 | let path =
38 | path
39 | ->Option.flatMap(path => path == "" ? None : Some(path))
40 | ->Option.map(path => ` path=${path};`)
41 | ->Option.getWithDefault("")
42 | let cookie = `${key}=${value};${expires}${path}`
43 |
44 | Webapi.Dom.HtmlDocument.setCookie(htmlDocument, cookie)
45 | }
46 |
47 | let setCookie = (key: string, value: option): unit => {
48 | open Constant
49 |
50 | let expires = Js.Date.make()
51 | let _ = Js.Date.setTime(expires, Js.Date.getTime(expires) +. Duration.monthInMs)
52 |
53 | setCookieRaw(~key, ~value?, ~expires=expires->Js.Date.toUTCString, ~path="/", ())
54 | }
55 |
56 | let deleteCookie = (key: string): unit =>
57 | setCookieRaw(~key, ~expires="Thu, 01 Jan 1970 00:00:01 GMT", ())
58 |
59 | let isMouseRightClick = event => {
60 | open ReactEvent
61 |
62 | !Mouse.defaultPrevented(event) &&
63 | Mouse.button(event) == 0 &&
64 | !Mouse.altKey(event) &&
65 | !Mouse.ctrlKey(event) &&
66 | !Mouse.metaKey(event) &&
67 | !Mouse.shiftKey(event)
68 | }
69 |
70 | let formatDate = (date: Js.Date.t): string => {
71 | let yyyy = date->Js.Date.getFullYear->Int.fromFloat->Int.toString
72 | let mm = date->Js.Date.getMonth->Int.fromFloat->Int.toString
73 | let dd = date->Js.Date.getDate->Int.fromFloat->Int.toString
74 |
75 | `${yyyy}/${mm}/${dd}`
76 | }
77 |
78 | module Json = {
79 | let decodeArrayString = (json: option): option> =>
80 | json
81 | ->Option.flatMap(Js.Json.decodeArray)
82 | ->Option.map(xs => xs->Array.filterMap(Js.Json.decodeString))
83 | }
84 |
--------------------------------------------------------------------------------
/vite.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import createReScriptPlugin from '@jihchi/vite-plugin-rescript';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), createReScriptPlugin()],
8 | });
9 |
--------------------------------------------------------------------------------