diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore
new file mode 100644
index 0000000..5d0850b
--- /dev/null
+++ b/apps/web/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+.env
+src
+public
+*.config.js
+*.config.ts
+index.html
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..8a9f8af
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,38 @@
+# ═══════════════════════════════════════════════════════════════════════════════
+# $APP_NAME — Frontend Environment Variables Template
+# Copy to .env and fill in all CHANGE_ME values before building.
+#
+# Self-hosted setup:
+# 1. cp .env.example .env
+# 2. Fill in every CHANGE_ME value below
+# 3. npm run build (bakes env vars into the dist/ bundle)
+# 4. docker build -t $APP_NAME-app .
+# (or serve dist/ with any static host — Nginx, Caddy, Vercel, etc.)
+#
+# NOTE: These are BUILD-TIME variables (VITE_ prefix). Changing them after
+# the build has no effect — you must rebuild to pick up new values.
+# ═══════════════════════════════════════════════════════════════════════════════
+
+# ── API Connection ────────────────────────────────────────────────────────────
+# Point to your backend's public URL — no trailing slash.
+# Must match APP_URL / ALLOWED_ORIGINS in the backend .env.
+VITE_APP_NAME=starr
+VITE_API_URL=https://api.yourdomain.com/api
+VITE_APP_URL=https://api.yourdomain.com
+
+# ── Environment ───────────────────────────────────────────────────────────────
+# "production" (droplet) or "qas" (home-hosted, behind Cloudflare Tunnel).
+# Set to "qas" only on the QAS Preview build in Vercel's project settings.
+VITE_APP_ENV=production
+
+# ── PayPal ────────────────────────────────────────────────────────────────────
+# Client ID is the public key — safe to expose in the browser bundle.
+# developer.paypal.com → My Apps & Credentials → Live tab → Client ID
+VITE_PAYPAL_CLIENT_ID=CHANGE_ME
+
+# ── Country detection (phone input) ────────────────────────────────────────────
+# IP → country lookup used to pre-select the phone input's dialing code.
+# Defaults to the free hosted instance; self-host for production via
+# https://github.com/lineofflight/country (Docker, needs a free MaxMind
+# license key) and point this at your own instance instead.
+VITE_COUNTRY_API_URL=https://api.country.is
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
new file mode 100644
index 0000000..bcd6d22
--- /dev/null
+++ b/apps/web/.gitignore
@@ -0,0 +1,49 @@
+# ── Dependencies ──────────────────────────────────────────────────────────────
+node_modules/
+
+# ── Environment / Secrets ─────────────────────────────────────────────────────
+.env
+.env.local
+.env.*.local
+# Keep .env.example — it is safe and documents required variables
+
+# ── SSL / TLS / Keys ──────────────────────────────────────────────────────────
+*.pem
+*.key
+*.cert
+*.crt
+*.p12
+*.pfx
+
+# ── Build / Output ────────────────────────────────────────────────────────────
+dist/
+dist-ssr/
+
+# ── Tauri ─────────────────────────────────────────────────────────────────────
+src-tauri/target/
+.tauri/
+
+# ── Logs ──────────────────────────────────────────────────────────────────────
+*.log
+logs/
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+lerna-debug.log*
+
+# ── OS ────────────────────────────────────────────────────────────────────────
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# ── Editors ───────────────────────────────────────────────────────────────────
+.vscode/*
+!.vscode/extensions.json
+.idea/
+*.swp
+*.swo
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+.vercel
diff --git a/apps/web/.vscode/extensions.json b/apps/web/.vscode/extensions.json
new file mode 100644
index 0000000..e69de29
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
new file mode 100644
index 0000000..a450ee2
--- /dev/null
+++ b/apps/web/Dockerfile
@@ -0,0 +1,9 @@
+# Pre-built image — run `npm run build` first so dist/ exists.
+FROM nginx:1.27-alpine
+
+COPY dist/ /usr/share/nginx/html/
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/web/LICENSE b/apps/web/LICENSE
new file mode 100644
index 0000000..c4662f9
--- /dev/null
+++ b/apps/web/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2025 [Kenneth Obsequio and Russell Obsequio]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000..92dc71f
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,177 @@
+# new_starr_app — Frontend
+
+React + Vite CSR frontend for the STARR LMS platform.
+
+---
+
+## Page Metadata
+
+Metadata (`
`, Open Graph, Twitter Card) is managed with **`react-helmet-async`**.
+The system has two layers:
+
+1. **Global fallback** — a `` in `App.jsx` always renders default tags. Any page that does not supply its own metadata falls back to this.
+2. **Per-page override** — each page renders a `` component anywhere in its JSX tree. react-helmet-async lets the deepest/last `` win, so the page always overrides the fallback.
+
+This approach fixes the `useNavigate()` stale-title bug: when you navigate away from a page, its `` unmounts and the global fallback immediately takes over.
+
+---
+
+### The component
+
+```jsx
+// src/contexts/MetadataContext.jsx
+import { PageMeta } from '@/contexts/MetadataContext'
+```
+
+**Props**
+
+| Prop | Type | Default |
+|---|---|---|
+| `title` | `string \| undefined` | `'STARR \| Philproperties'` |
+| `description` | `string \| undefined` | app default description |
+| `keywords` | `string \| undefined` | app default keywords |
+| `ogImage` | `string \| undefined` | app default OG image URL |
+| `ogType` | `string` | `'website'` |
+
+Every prop is optional. Any omitted prop falls back to the app-level default.
+Passing `title={undefined}` explicitly is the same as omitting it — the fallback title renders.
+
+---
+
+### Static pages
+
+Pages with no data dependency just pass a hardcoded string.
+
+```jsx
+import { PageMeta } from '@/contexts/MetadataContext'
+
+export default function CourseList() {
+ return (
+
+
+ {/* rest of page */}
+
+ )
+}
+```
+
+---
+
+### Dynamic pages — data from context
+
+When the page title comes from an API response, pass the derived string.
+Use `undefined` (not a hardcoded fallback string) while the data is loading — this lets the global default render during the loading state and then automatically updates when data arrives.
+
+```jsx
+import { PageMeta } from '@/contexts/MetadataContext'
+import { useCourses } from '@/contexts/AdminCoursesContext'
+
+export default function ViewCourse() {
+ const { course } = useCourses()
+
+ return (
+
+ {/* undefined while course is null → global default renders */}
+
+ {/* rest of page */}
+
+ )
+}
+```
+
+---
+
+### Dynamic pages — data in local state
+
+Same pattern when the title comes from local `useState` instead of context.
+
+```jsx
+import { PageMeta } from '@/contexts/MetadataContext'
+
+export default function EditUnit() {
+ const [unitTitle, setUnitTitle] = useState('')
+ // ... fetch and setUnitTitle on load
+
+ return (
+
+
+ {/* rest of page */}
+
+ )
+}
+```
+
+---
+
+### Placement rules
+
+- Drop `` as the **first child** of the root element in the page's main `return`.
+- Do **not** add it to early/loading returns (spinner-only returns). The global fallback covers those.
+- Do **not** wrap the entire page in `` — it is an inline element, not a wrapper.
+
+```jsx
+// CORRECT — inline inside root element
+return (
+
+
+
...
+
+)
+
+// WRONG — do not use as a wrapper
+return (
+
+ ...
+
+)
+```
+
+---
+
+### Title convention
+
+| Page type | Pattern | Example |
+|---|---|---|
+| List | `{Entity} - STARR` | `Courses - STARR` |
+| Archived list | `Archived {Entity} - STARR` | `Archived Courses - STARR` |
+| Create | `Add {Entity} - STARR` | `Add Course - STARR` |
+| View (dynamic) | `{name} - STARR` | `Intro to Sales - STARR` |
+| Edit (dynamic) | `Edit: {name} - STARR` | `Edit: Intro to Sales - STARR` |
+| Sub-list | `{SubEntity} – {parentName} - STARR` | `Units – Intro to Sales - STARR` |
+| Nested action | `{Action} – {parentName} - STARR` | `Page Builder – Lesson 1 - STARR` |
+
+---
+
+### Adding metadata to a new page — checklist
+
+1. Import `PageMeta` from `@/contexts/MetadataContext`.
+2. Identify what data (if any) drives the title — context state, local state, or nothing.
+3. Place `` as the first child of the root element in the main `return`.
+4. Use the title convention above.
+5. Pass `description` if the page has meaningful content to describe (e.g. a course description).
+6. Pass `title={undefined}` (or omit `title`) while data is still loading.
+
+---
+
+### MetadataProvider (legacy)
+
+`Login.jsx` and `Register.jsx` still use the old wrapper pattern via `MetadataProvider`. This is a backward-compat shim that internally renders `` and then its children. It works but should not be used for new pages — use the inline `` pattern instead.
+
+```jsx
+// Old pattern (Login / Register only — do not copy for new pages)
+
+
+
+
+// New pattern (use this for all new pages)
+
+
+
+
+```
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 0000000..e94d87e
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "radix-nova",
+ "rsc": false,
+ "tsx": false,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {
+ "@coss": "https://coss.com/ui/r/{name}.json"
+ }
+}
diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js
new file mode 100644
index 0000000..ea36dd3
--- /dev/null
+++ b/apps/web/eslint.config.js
@@ -0,0 +1,21 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+ },
+])
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..42e235c
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+ %VITE_APP_NAME%
+
+
+
+
+
+
diff --git a/apps/web/jsconfig.json b/apps/web/jsconfig.json
new file mode 100644
index 0000000..e8b3494
--- /dev/null
+++ b/apps/web/jsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/.sourcedirs.json b/apps/web/lib/bs/.sourcedirs.json
new file mode 100644
index 0000000..01b8a62
--- /dev/null
+++ b/apps/web/lib/bs/.sourcedirs.json
@@ -0,0 +1 @@
+{"cmt_scan":[{"also_scan_build_root":true,"build_root":"lib/bs","scan_dirs":["src/modules/client/hooks/rescript","src/modules/client/hooks/rescript/canvas"]}],"dirs":["src/modules/client/hooks/rescript","src/modules/client/hooks/rescript/canvas"],"generated":[],"pkgs":[["@rescript/react","/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+react@0.15.0_@rescript+runtime@12.3.0_react-dom@19.2.5_react@19.2.5__react@19.2.5/node_modules/@rescript/react"]],"version":2}
\ No newline at end of file
diff --git a/apps/web/lib/bs/build.ninja b/apps/web/lib/bs/build.ninja
new file mode 100644
index 0000000..e69de29
diff --git a/apps/web/lib/bs/compiler-info.json b/apps/web/lib/bs/compiler-info.json
new file mode 100644
index 0000000..3131cd1
--- /dev/null
+++ b/apps/web/lib/bs/compiler-info.json
@@ -0,0 +1,8 @@
+{
+ "version": "12.3.0",
+ "bsc_path": "/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+linux-x64@12.3.0/node_modules/@rescript/linux-x64/bin/bsc.exe",
+ "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
+ "rescript_config_hash": "d85ec7cb9b3b80783c8953553f7875d1b9d94eb55dcfd9a57b3ba357830c1934",
+ "runtime_path": "/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+runtime@12.3.0/node_modules/@rescript/runtime",
+ "generated_at": "1779801549320"
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.ast
new file mode 100644
index 0000000..ff57b58
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmi
new file mode 100644
index 0000000..6a2b4f0
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmj
new file mode 100644
index 0000000..615482c
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmt
new file mode 100644
index 0000000..6917c82
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.js
new file mode 100644
index 0000000..debb90b
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.js
@@ -0,0 +1,58 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as JsxRuntime from "react/jsx-runtime";
+
+function AnnotationPanel(props) {
+ let onClose = props.onClose;
+ let onNoteChange = props.onNoteChange;
+ let note = props.note;
+ let lesson = props.lesson;
+ let noteValue = note !== undefined ? note : "";
+ return JsxRuntime.jsxs("div", {
+ children: [
+ JsxRuntime.jsxs("div", {
+ children: [
+ JsxRuntime.jsxs("div", {
+ children: [
+ JsxRuntime.jsx("p", {
+ children: "Annotation",
+ className: "text-xs text-muted-foreground uppercase tracking-widest font-semibold"
+ }),
+ JsxRuntime.jsx("h2", {
+ children: lesson.title,
+ className: "text-lg font-bold mt-0.5"
+ })
+ ]
+ }),
+ JsxRuntime.jsx("button", {
+ children: "✕ Close",
+ className: "text-muted-foreground hover:text-foreground transition-colors text-sm",
+ onClick: param => onClose()
+ })
+ ],
+ className: "flex items-center justify-between mb-4"
+ }),
+ JsxRuntime.jsx("textarea", {
+ className: "flex-1 w-full resize-none rounded-lg border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
+ placeholder: "Write your notes for this lesson...",
+ value: noteValue,
+ onChange: e => {
+ let value = e.target.value;
+ onNoteChange(lesson.id, value);
+ }
+ }),
+ JsxRuntime.jsx("p", {
+ children: "Auto-saved to your browser",
+ className: "text-xs text-muted-foreground mt-2"
+ })
+ ],
+ className: "flex flex-col h-full"
+ });
+}
+
+let make = AnnotationPanel;
+
+export {
+ make,
+}
+/* react/jsx-runtime Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.res
new file mode 100644
index 0000000..6acb6d5
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationPanel.res
@@ -0,0 +1,43 @@
+@react.component
+let make = (
+ ~lesson: AnnotationTypes.lesson,
+ ~note: option,
+ ~onNoteChange: (string, string) => unit,
+ ~onClose: unit => unit,
+) => {
+ let noteValue = switch note {
+ | Some(n) => n
+ | None => ""
+ }
+
+
+
+
+
+ {React.string("Annotation")}
+
+
+ {React.string(lesson.title)}
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.ast
new file mode 100644
index 0000000..ea6f70c
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmi
new file mode 100644
index 0000000..d5424a3
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmj
new file mode 100644
index 0000000..19c9ebe
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmt
new file mode 100644
index 0000000..fe6ca5b
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.js
new file mode 100644
index 0000000..4e01749
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.js
@@ -0,0 +1,29 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
+
+function storageKey(courseId, lessonId) {
+ return `annotation:` + courseId + `:` + lessonId;
+}
+
+function saveNote(courseId, lessonId, note) {
+ let key = storageKey(courseId, lessonId);
+ return localStorage.setItem(key, note);
+}
+
+function loadNote(courseId, lessonId) {
+ let key = storageKey(courseId, lessonId);
+ return Primitive_option.fromNullable(localStorage.getItem(key));
+}
+
+function deleteNote(courseId, lessonId) {
+ return localStorage.removeItem(storageKey(courseId, lessonId));
+}
+
+export {
+ storageKey,
+ saveNote,
+ loadNote,
+ deleteNote,
+}
+/* No side effect */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.res
new file mode 100644
index 0000000..4763bac
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationStorage.res
@@ -0,0 +1,20 @@
+@val external localStorage: {..} = "localStorage"
+
+let storageKey = (courseId: string, lessonId: string) =>
+ `annotation:${courseId}:${lessonId}`
+
+let saveNote = (courseId: string, lessonId: string, note: string) => {
+ let key = storageKey(courseId, lessonId)
+ localStorage["setItem"](key, note)
+}
+
+let loadNote = (courseId: string, lessonId: string) => {
+ let key = storageKey(courseId, lessonId)
+ let value: Nullable.t = localStorage["getItem"](key)
+ Nullable.toOption(value)
+}
+
+let deleteNote = (courseId: string, lessonId: string) => {
+ let key = storageKey(courseId, lessonId)
+ localStorage["removeItem"](key)
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.ast
new file mode 100644
index 0000000..0c1a30f
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmi
new file mode 100644
index 0000000..6aa711f
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmj
new file mode 100644
index 0000000..bb77606
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmt
new file mode 100644
index 0000000..dfc3ce2
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.js
new file mode 100644
index 0000000..188d4f9
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.js
@@ -0,0 +1,15 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+
+let initialState_notes = {};
+
+let initialState = {
+ selectedLesson: undefined,
+ isPenOpen: false,
+ notes: initialState_notes
+};
+
+export {
+ initialState,
+}
+/* No side effect */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.res
new file mode 100644
index 0000000..900f30b
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/AnnotationTypes.res
@@ -0,0 +1,17 @@
+type lesson = {
+ id: string,
+ title: string,
+ unitId: string,
+}
+
+type annotationState = {
+ selectedLesson: option,
+ isPenOpen: bool,
+ notes: dict,
+}
+
+let initialState = {
+ selectedLesson: None,
+ isPenOpen: false,
+ notes: Dict.make(),
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.ast
new file mode 100644
index 0000000..72a181d
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmi
new file mode 100644
index 0000000..d48034a
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmj
new file mode 100644
index 0000000..615482c
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmt
new file mode 100644
index 0000000..ba73d6a
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.js
new file mode 100644
index 0000000..03bfdb6
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.js
@@ -0,0 +1,16 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as JsxRuntime from "react/jsx-runtime";
+
+function Hello(props) {
+ return JsxRuntime.jsx("div", {
+ children: "ReScript is working!"
+ });
+}
+
+let make = Hello;
+
+export {
+ make,
+}
+/* react/jsx-runtime Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.res
new file mode 100644
index 0000000..201d496
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/Hello.res
@@ -0,0 +1,4 @@
+@react.component
+let make = () => {
+
{React.string("ReScript is working!")}
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.ast
new file mode 100644
index 0000000..0c35b4a
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmi
new file mode 100644
index 0000000..0900749
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmj
new file mode 100644
index 0000000..1be3490
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmt
new file mode 100644
index 0000000..b64bcee
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.js
new file mode 100644
index 0000000..2665359
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.js
@@ -0,0 +1,192 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as React from "react";
+import * as UseSketch from "./useSketch.js";
+import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
+import * as JsxRuntime from "react/jsx-runtime";
+import SketchToolbarJsx from "./SketchToolbar.jsx";
+
+let make = SketchToolbarJsx;
+
+let SketchToolbar = {
+ make: make
+};
+
+function SketchCanvas(props) {
+ let onClear = props.onClear;
+ let onSave = props.onSave;
+ let color = props.color;
+ let tool = props.tool;
+ let isActive = props.isActive;
+ let canvasRef = React.useRef(null);
+ let isDrawing = React.useRef(false);
+ let lastPoint = React.useRef(undefined);
+ React.useEffect(() => {
+ let el = canvasRef.current;
+ if (!(el == null)) {
+ let parent = el.parentElement;
+ el.width = parent.offsetWidth;
+ el.height = parent.offsetHeight;
+ }
+ }, [isActive]);
+ let getPos = (e, canvas) => {
+ let rect = canvas.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+ };
+ let startDraw = e => {
+ let el = canvasRef.current;
+ if (el == null) {
+ return;
+ }
+ isDrawing.current = true;
+ let pos = getPos(e, el);
+ lastPoint.current = pos;
+ if (tool === "Highlighter") {
+ let ctx = el.getContext("2d");
+ let hex = UseSketch.colorToHex(color);
+ let width = UseSketch.toolToWidth(tool, 2.0);
+ ctx.globalAlpha = 0.25;
+ ctx.globalCompositeOperation = "source-over";
+ ctx.strokeStyle = hex;
+ ctx.lineWidth = width;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.beginPath();
+ ctx.moveTo(pos.x, pos.y);
+ return;
+ }
+ if (tool !== "Text") {
+ return;
+ }
+ let text = el.ownerDocument.defaultView.prompt("Enter text:", "");
+ if (text == null) {
+ return;
+ }
+ let ctx$1 = el.getContext("2d");
+ let hex$1 = UseSketch.colorToHex(color);
+ ctx$1.globalAlpha = 1.0;
+ ctx$1.fillStyle = hex$1;
+ ctx$1.font = "bold 16px sans-serif";
+ ctx$1.fillText(text, pos.x, pos.y);
+ };
+ let draw = e => {
+ if (!(isDrawing.current && tool !== "Text")) {
+ return;
+ }
+ let el = canvasRef.current;
+ if (el == null) {
+ return;
+ }
+ let last = lastPoint.current;
+ if (last === undefined) {
+ return;
+ }
+ let ctx = el.getContext("2d");
+ let pos = getPos(e, el);
+ let hex = UseSketch.colorToHex(color);
+ let width = UseSketch.toolToWidth(tool, 2.0);
+ let exit = 0;
+ switch (tool) {
+ case "Highlighter" :
+ ctx.lineTo(pos.x, pos.y);
+ ctx.stroke();
+ break;
+ case "Pen" :
+ case "Text" :
+ exit = 1;
+ break;
+ }
+ if (exit === 1) {
+ ctx.globalAlpha = 1.0;
+ ctx.strokeStyle = hex;
+ ctx.lineWidth = width;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.beginPath();
+ ctx.moveTo(last.x, last.y);
+ ctx.quadraticCurveTo(last.x + (pos.x - last.x) / 2.0, last.y + (pos.y - last.y) / 2.0, pos.x, pos.y);
+ ctx.stroke();
+ }
+ lastPoint.current = pos;
+ };
+ let stopDraw = _e => {
+ if (tool === "Highlighter") {
+ let el = canvasRef.current;
+ if (!(el == null)) {
+ let ctx = el.getContext("2d");
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+ ctx.globalCompositeOperation = "source-over";
+ }
+ }
+ isDrawing.current = false;
+ lastPoint.current = undefined;
+ };
+ let handleClear = () => {
+ let el = canvasRef.current;
+ if (el == null) {
+ return;
+ }
+ let ctx = el.getContext("2d");
+ ctx.clearRect(0, 0, el.width, el.height);
+ onClear();
+ };
+ let handleSave = () => {
+ let el = canvasRef.current;
+ if (el == null) {
+ return;
+ }
+ let dataUrl = el.toDataURL("image/png");
+ let link = document.createElement("a");
+ link.href = dataUrl;
+ link.download = "sketch.png";
+ link.click();
+ onSave();
+ };
+ if (isActive) {
+ return JsxRuntime.jsxs("div", {
+ children: [
+ JsxRuntime.jsx(make, {
+ state: {
+ isActive: isActive,
+ tool: tool,
+ color: color,
+ strokeWidth: 2.0
+ },
+ onSetTool: props.onSetTool,
+ onSetColor: props.onSetColor,
+ onClear: handleClear,
+ onSave: handleSave,
+ onClose: props.onClose
+ }),
+ JsxRuntime.jsx("canvas", {
+ ref: Primitive_option.some(canvasRef),
+ className: "w-full h-full cursor-crosshair",
+ height: "100%",
+ width: "100%",
+ onMouseDown: startDraw,
+ onMouseLeave: stopDraw,
+ onMouseMove: draw,
+ onMouseUp: stopDraw
+ })
+ ],
+ className: "absolute inset-0 z-10",
+ style: {
+ pointerEvents: isActive ? "all" : "none"
+ }
+ });
+ } else {
+ return null;
+ }
+}
+
+let make$1 = SketchCanvas;
+
+export {
+ SketchToolbar,
+ make$1 as make,
+}
+/* make Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.res
new file mode 100644
index 0000000..37b7457
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchCanvas.res
@@ -0,0 +1,198 @@
+module SketchToolbar = {
+ @module("./SketchToolbar.jsx") @react.component
+ external make: (
+ ~state: UseSketch.sketchState,
+ ~onSetTool: UseSketch.tool => unit,
+ ~onSetColor: UseSketch.color => unit,
+ ~onClear: unit => unit,
+ ~onSave: unit => unit,
+ ~onClose: unit => unit,
+ ) => React.element = "default"
+}
+
+type point = {x: float, y: float}
+
+@val external document: {..} = "document"
+external asCanvas: Dom.element => {..} = "%identity"
+
+@react.component
+let make = (
+ ~isActive: bool,
+ ~tool: UseSketch.tool,
+ ~color: UseSketch.color,
+ ~onSave: unit => unit,
+ ~onClear: unit => unit,
+ ~onClose: unit => unit,
+ ~onSetTool: UseSketch.tool => unit,
+ ~onSetColor: UseSketch.color => unit,
+) => {
+ let canvasRef: React.ref> = React.useRef(Nullable.null)
+ let isDrawing = React.useRef(false)
+ let lastPoint = React.useRef(None)
+
+ React.useEffect1(() => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let parent = canvas["parentElement"]
+ canvas["width"] = parent["offsetWidth"]
+ canvas["height"] = parent["offsetHeight"]
+ }
+ None
+ }, [isActive])
+
+ let getPos = (e: JsxEvent.Mouse.t, canvas) => {
+ let rect = canvas["getBoundingClientRect"]()
+ {
+ x: Float.fromInt(JsxEvent.Mouse.clientX(e)) -. rect["left"],
+ y: Float.fromInt(JsxEvent.Mouse.clientY(e)) -. rect["top"],
+ }
+ }
+
+ let startDraw = (e: JsxEvent.Mouse.t) => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ isDrawing.current = true
+ let pos = getPos(e, canvas)
+ lastPoint.current = Some(pos)
+ if tool === UseSketch.Highlighter {
+ let ctx = canvas["getContext"]("2d")
+ let hex = UseSketch.colorToHex(color)
+ let width = UseSketch.toolToWidth(tool, 2.0)
+ ctx["globalAlpha"] = 0.25
+ ctx["globalCompositeOperation"] = "source-over"
+ ctx["strokeStyle"] = hex
+ ctx["lineWidth"] = width
+ ctx["lineCap"] = "round"
+ ctx["lineJoin"] = "round"
+ let _ = ctx["beginPath"]()
+ let _ = ctx["moveTo"](pos.x, pos.y)
+ } else if tool === UseSketch.Text {
+ let text = canvas["ownerDocument"]["defaultView"]["prompt"]("Enter text:", "")
+ switch Nullable.toOption(text) {
+ | None => ()
+ | Some(t) =>
+ let ctx = canvas["getContext"]("2d")
+ let hex = UseSketch.colorToHex(color)
+ ctx["globalAlpha"] = 1.0
+ ctx["fillStyle"] = hex
+ ctx["font"] = "bold 16px sans-serif"
+ let _ = ctx["fillText"](t, pos.x, pos.y)
+ }
+ }
+ }
+ }
+
+ let draw = (e: JsxEvent.Mouse.t) => {
+ if isDrawing.current && tool !== UseSketch.Text {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ switch lastPoint.current {
+ | None => ()
+ | Some(last) =>
+ let ctx = canvas["getContext"]("2d")
+ let pos = getPos(e, canvas)
+ let hex = UseSketch.colorToHex(color)
+ let width = UseSketch.toolToWidth(tool, 2.0)
+ switch tool {
+ | UseSketch.Highlighter =>
+ let _ = ctx["lineTo"](pos.x, pos.y)
+ let _ = ctx["stroke"]()
+ | _ =>
+ ctx["globalAlpha"] = 1.0
+ ctx["strokeStyle"] = hex
+ ctx["lineWidth"] = width
+ ctx["lineCap"] = "round"
+ ctx["lineJoin"] = "round"
+ let _ = ctx["beginPath"]()
+ let _ = ctx["moveTo"](last.x, last.y)
+ let _ = ctx["quadraticCurveTo"](
+ last.x +. (pos.x -. last.x) /. 2.0,
+ last.y +. (pos.y -. last.y) /. 2.0,
+ pos.x,
+ pos.y,
+ )
+ let _ = ctx["stroke"]()
+ }
+ lastPoint.current = Some(pos)
+ }
+ }
+ }
+ }
+
+ let stopDraw = (_e: JsxEvent.Mouse.t) => {
+ if tool === UseSketch.Highlighter {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let ctx = canvas["getContext"]("2d")
+ let _ = ctx["stroke"]()
+ ctx["globalAlpha"] = 1.0
+ ctx["globalCompositeOperation"] = "source-over"
+ }
+ }
+ isDrawing.current = false
+ lastPoint.current = None
+ }
+
+ let handleClear = () => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let ctx = canvas["getContext"]("2d")
+ let _ = ctx["clearRect"](0, 0, canvas["width"], canvas["height"])
+ onClear()
+ }
+ }
+
+ let handleSave = () => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let dataUrl = canvas["toDataURL"]("image/png")
+ let link = document["createElement"]("a")
+ link["href"] = dataUrl
+ link["download"] = "sketch.png"
+ let _ = link["click"]()
+ onSave()
+ }
+ }
+
+ if !isActive {
+ React.null
+ } else {
+
+
+
+ }
+}
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.ast
new file mode 100644
index 0000000..bddb198
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmi
new file mode 100644
index 0000000..08fbd39
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmj
new file mode 100644
index 0000000..ddcaf15
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmt
new file mode 100644
index 0000000..d3599a0
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.js
new file mode 100644
index 0000000..1aa051d
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.js
@@ -0,0 +1,14 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import SketchToolbarJsx from "./SketchToolbar.jsx";
+
+let make = SketchToolbarJsx;
+
+let SketchToolbar = {
+ make: make
+};
+
+export {
+ SketchToolbar,
+}
+/* make Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.res
new file mode 100644
index 0000000..7394c20
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/SketchToolbar.res
@@ -0,0 +1,11 @@
+module SketchToolbar = {
+ @module("./SketchToolbar.jsx") @react.component
+ external make: (
+ ~state: UseSketch.sketchState,
+ ~onSetTool: UseSketch.tool => unit,
+ ~onSetColor: UseSketch.color => unit,
+ ~onClear: unit => unit,
+ ~onSave: unit => unit,
+ ~onClose: unit => unit,
+ ) => React.element = "default"
+}
\ No newline at end of file
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.ast
new file mode 100644
index 0000000..da8675b
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmi
new file mode 100644
index 0000000..09e2048
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmj
new file mode 100644
index 0000000..9ebcf08
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmt
new file mode 100644
index 0000000..d29a878
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.js
new file mode 100644
index 0000000..4f8672e
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.js
@@ -0,0 +1,106 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as React from "react";
+
+let initialState = {
+ isActive: false,
+ tool: "Pen",
+ color: "Black",
+ strokeWidth: 2.0
+};
+
+function colorToHex(color) {
+ switch (color) {
+ case "Black" :
+ return "#000000";
+ case "Red" :
+ return "#ef4444";
+ case "Blue" :
+ return "#3b82f6";
+ case "Yellow" :
+ return "#eab308";
+ }
+}
+
+function toolToOpacity(tool) {
+ switch (tool) {
+ case "Highlighter" :
+ return 0.3;
+ case "Pen" :
+ case "Text" :
+ return 1.0;
+ }
+}
+
+function isHighlighter(tool) {
+ switch (tool) {
+ case "Highlighter" :
+ return true;
+ case "Pen" :
+ case "Text" :
+ return false;
+ }
+}
+
+function toolToWidth(tool, base) {
+ switch (tool) {
+ case "Highlighter" :
+ return base * 8.0;
+ case "Pen" :
+ case "Text" :
+ return base;
+ }
+}
+
+function use() {
+ let match = React.useState(() => initialState);
+ let setState = match[1];
+ let activate = () => setState(prev => ({
+ isActive: true,
+ tool: prev.tool,
+ color: prev.color,
+ strokeWidth: prev.strokeWidth
+ }));
+ let deactivate = () => setState(prev => ({
+ isActive: false,
+ tool: prev.tool,
+ color: prev.color,
+ strokeWidth: prev.strokeWidth
+ }));
+ let toggle = () => setState(prev => ({
+ isActive: !prev.isActive,
+ tool: prev.tool,
+ color: prev.color,
+ strokeWidth: prev.strokeWidth
+ }));
+ let setTool = tool => setState(prev => ({
+ isActive: prev.isActive,
+ tool: tool,
+ color: prev.color,
+ strokeWidth: prev.strokeWidth
+ }));
+ let setColor = color => setState(prev => ({
+ isActive: prev.isActive,
+ tool: prev.tool,
+ color: color,
+ strokeWidth: prev.strokeWidth
+ }));
+ return [
+ match[0],
+ activate,
+ deactivate,
+ toggle,
+ setTool,
+ setColor
+ ];
+}
+
+export {
+ initialState,
+ colorToHex,
+ toolToOpacity,
+ isHighlighter,
+ toolToWidth,
+ use,
+}
+/* react Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.res
new file mode 100644
index 0000000..1e87469
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/canvas/useSketch.res
@@ -0,0 +1,67 @@
+type tool =
+ | Pen
+ | Highlighter
+ | Text
+
+type color =
+ | Black
+ | Red
+ | Blue
+ | Yellow
+
+type sketchState = {
+ isActive: bool,
+ tool: tool,
+ color: color,
+ strokeWidth: float,
+}
+
+let initialState: sketchState = {
+ isActive: false,
+ tool: Pen,
+ color: Black,
+ strokeWidth: 2.0,
+}
+
+let colorToHex = (color: color) =>
+ switch color {
+ | Black => "#000000"
+ | Red => "#ef4444"
+ | Blue => "#3b82f6"
+ | Yellow => "#eab308"
+ }
+
+let toolToOpacity = (tool: tool) =>
+ switch tool {
+ | Highlighter => 0.3
+ | Pen | Text => 1.0
+ }
+
+let isHighlighter = (tool: tool) =>
+ switch tool {
+ | Highlighter => true
+ | Pen | Text => false
+ }
+
+let toolToWidth = (tool: tool, base: float) =>
+ switch tool {
+ | Highlighter => base *. 8.0
+ | Pen => base
+ | Text => base
+ }
+
+let use = () => {
+ let (state, setState) = React.useState(() => initialState)
+
+ let activate = () => setState(prev => {...prev, isActive: true})
+
+ let deactivate = () => setState(prev => {...prev, isActive: false})
+
+ let toggle = () => setState(prev => {...prev, isActive: !prev.isActive})
+
+ let setTool = (tool: tool) => setState(prev => {...prev, tool})
+
+ let setColor = (color: color) => setState(prev => {...prev, color})
+
+ (state, activate, deactivate, toggle, setTool, setColor)
+}
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.ast b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.ast
new file mode 100644
index 0000000..d074bd1
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.ast differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmi b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmi
new file mode 100644
index 0000000..b958f4e
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmi differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmj b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmj
new file mode 100644
index 0000000..dbed5e6
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmj differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmt b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmt
new file mode 100644
index 0000000..5c0822b
Binary files /dev/null and b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.cmt differ
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.js b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.js
new file mode 100644
index 0000000..4b57f53
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.js
@@ -0,0 +1,53 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as React from "react";
+import * as AnnotationTypes from "./AnnotationTypes.js";
+import * as AnnotationStorage from "./AnnotationStorage.js";
+
+function use(courseId) {
+ let match = React.useState(() => AnnotationTypes.initialState);
+ let setState = match[1];
+ let state = match[0];
+ let selectLesson = lesson => {
+ let savedNote = AnnotationStorage.loadNote(courseId, lesson.id);
+ let notes = {};
+ if (savedNote !== undefined) {
+ notes[lesson.id] = savedNote;
+ }
+ setState(param => ({
+ selectedLesson: lesson,
+ isPenOpen: true,
+ notes: notes
+ }));
+ };
+ let updateNote = (lessonId, note) => {
+ AnnotationStorage.saveNote(courseId, lessonId, note);
+ setState(prev => {
+ let notes = {};
+ notes[lessonId] = note;
+ return {
+ selectedLesson: prev.selectedLesson,
+ isPenOpen: prev.isPenOpen,
+ notes: notes
+ };
+ });
+ };
+ let closePen = () => setState(prev => ({
+ selectedLesson: undefined,
+ isPenOpen: false,
+ notes: prev.notes
+ }));
+ let getNote = lessonId => state.notes[lessonId];
+ return [
+ state,
+ selectLesson,
+ updateNote,
+ closePen,
+ getNote
+ ];
+}
+
+export {
+ use,
+}
+/* react Not a pure module */
diff --git a/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.res b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.res
new file mode 100644
index 0000000..b555acc
--- /dev/null
+++ b/apps/web/lib/bs/src/modules/client/hooks/rescript/useAnnotation.res
@@ -0,0 +1,38 @@
+@val external window: {..} = "window"
+
+let use = (courseId: string) => {
+ let (state, setState) = React.useState(() => AnnotationTypes.initialState)
+
+ let selectLesson = (lesson: AnnotationTypes.lesson) => {
+ let savedNote = AnnotationStorage.loadNote(courseId, lesson.id)
+ let notes = Dict.make()
+ switch savedNote {
+ | Some(note) => Dict.set(notes, lesson.id, note)
+ | None => ()
+ }
+ setState(_ => {
+ selectedLesson: Some(lesson),
+ isPenOpen: true,
+ notes,
+ })
+ }
+
+ let updateNote = (lessonId: string, note: string) => {
+ AnnotationStorage.saveNote(courseId, lessonId, note)
+ setState(prev => {
+ let notes = Dict.make()
+ Dict.set(notes, lessonId, note)
+ {...prev, notes}
+ })
+ }
+
+ let closePen = () => {
+ setState(prev => {...prev, isPenOpen: false, selectedLesson: None})
+ }
+
+ let getNote = (lessonId: string) => {
+ Dict.get(state.notes, lessonId)
+ }
+
+ (state, selectLesson, updateNote, closePen, getNote)
+}
\ No newline at end of file
diff --git a/apps/web/lib/ocaml/AnnotationPanel.res b/apps/web/lib/ocaml/AnnotationPanel.res
new file mode 100644
index 0000000..6acb6d5
--- /dev/null
+++ b/apps/web/lib/ocaml/AnnotationPanel.res
@@ -0,0 +1,43 @@
+@react.component
+let make = (
+ ~lesson: AnnotationTypes.lesson,
+ ~note: option,
+ ~onNoteChange: (string, string) => unit,
+ ~onClose: unit => unit,
+) => {
+ let noteValue = switch note {
+ | Some(n) => n
+ | None => ""
+ }
+
+
+
+
+
+ {React.string("Annotation")}
+
+
+ {React.string(lesson.title)}
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/apps/web/lib/ocaml/AnnotationStorage.ast b/apps/web/lib/ocaml/AnnotationStorage.ast
new file mode 100644
index 0000000..ea6f70c
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationStorage.ast differ
diff --git a/apps/web/lib/ocaml/AnnotationStorage.cmi b/apps/web/lib/ocaml/AnnotationStorage.cmi
new file mode 100644
index 0000000..d5424a3
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationStorage.cmi differ
diff --git a/apps/web/lib/ocaml/AnnotationStorage.cmj b/apps/web/lib/ocaml/AnnotationStorage.cmj
new file mode 100644
index 0000000..19c9ebe
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationStorage.cmj differ
diff --git a/apps/web/lib/ocaml/AnnotationStorage.cmt b/apps/web/lib/ocaml/AnnotationStorage.cmt
new file mode 100644
index 0000000..fe6ca5b
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationStorage.cmt differ
diff --git a/apps/web/lib/ocaml/AnnotationStorage.res b/apps/web/lib/ocaml/AnnotationStorage.res
new file mode 100644
index 0000000..4763bac
--- /dev/null
+++ b/apps/web/lib/ocaml/AnnotationStorage.res
@@ -0,0 +1,20 @@
+@val external localStorage: {..} = "localStorage"
+
+let storageKey = (courseId: string, lessonId: string) =>
+ `annotation:${courseId}:${lessonId}`
+
+let saveNote = (courseId: string, lessonId: string, note: string) => {
+ let key = storageKey(courseId, lessonId)
+ localStorage["setItem"](key, note)
+}
+
+let loadNote = (courseId: string, lessonId: string) => {
+ let key = storageKey(courseId, lessonId)
+ let value: Nullable.t = localStorage["getItem"](key)
+ Nullable.toOption(value)
+}
+
+let deleteNote = (courseId: string, lessonId: string) => {
+ let key = storageKey(courseId, lessonId)
+ localStorage["removeItem"](key)
+}
\ No newline at end of file
diff --git a/apps/web/lib/ocaml/AnnotationTypes.ast b/apps/web/lib/ocaml/AnnotationTypes.ast
new file mode 100644
index 0000000..0c1a30f
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationTypes.ast differ
diff --git a/apps/web/lib/ocaml/AnnotationTypes.cmi b/apps/web/lib/ocaml/AnnotationTypes.cmi
new file mode 100644
index 0000000..6aa711f
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationTypes.cmi differ
diff --git a/apps/web/lib/ocaml/AnnotationTypes.cmj b/apps/web/lib/ocaml/AnnotationTypes.cmj
new file mode 100644
index 0000000..bb77606
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationTypes.cmj differ
diff --git a/apps/web/lib/ocaml/AnnotationTypes.cmt b/apps/web/lib/ocaml/AnnotationTypes.cmt
new file mode 100644
index 0000000..dfc3ce2
Binary files /dev/null and b/apps/web/lib/ocaml/AnnotationTypes.cmt differ
diff --git a/apps/web/lib/ocaml/AnnotationTypes.res b/apps/web/lib/ocaml/AnnotationTypes.res
new file mode 100644
index 0000000..900f30b
--- /dev/null
+++ b/apps/web/lib/ocaml/AnnotationTypes.res
@@ -0,0 +1,17 @@
+type lesson = {
+ id: string,
+ title: string,
+ unitId: string,
+}
+
+type annotationState = {
+ selectedLesson: option,
+ isPenOpen: bool,
+ notes: dict,
+}
+
+let initialState = {
+ selectedLesson: None,
+ isPenOpen: false,
+ notes: Dict.make(),
+}
\ No newline at end of file
diff --git a/apps/web/lib/ocaml/Hello.res b/apps/web/lib/ocaml/Hello.res
new file mode 100644
index 0000000..201d496
--- /dev/null
+++ b/apps/web/lib/ocaml/Hello.res
@@ -0,0 +1,4 @@
+@react.component
+let make = () => {
+
{React.string("ReScript is working!")}
+}
\ No newline at end of file
diff --git a/apps/web/lib/ocaml/SketchCanvas.ast b/apps/web/lib/ocaml/SketchCanvas.ast
new file mode 100644
index 0000000..0c35b4a
Binary files /dev/null and b/apps/web/lib/ocaml/SketchCanvas.ast differ
diff --git a/apps/web/lib/ocaml/SketchCanvas.cmi b/apps/web/lib/ocaml/SketchCanvas.cmi
new file mode 100644
index 0000000..0900749
Binary files /dev/null and b/apps/web/lib/ocaml/SketchCanvas.cmi differ
diff --git a/apps/web/lib/ocaml/SketchCanvas.cmj b/apps/web/lib/ocaml/SketchCanvas.cmj
new file mode 100644
index 0000000..1be3490
Binary files /dev/null and b/apps/web/lib/ocaml/SketchCanvas.cmj differ
diff --git a/apps/web/lib/ocaml/SketchCanvas.cmt b/apps/web/lib/ocaml/SketchCanvas.cmt
new file mode 100644
index 0000000..b64bcee
Binary files /dev/null and b/apps/web/lib/ocaml/SketchCanvas.cmt differ
diff --git a/apps/web/lib/ocaml/SketchCanvas.res b/apps/web/lib/ocaml/SketchCanvas.res
new file mode 100644
index 0000000..37b7457
--- /dev/null
+++ b/apps/web/lib/ocaml/SketchCanvas.res
@@ -0,0 +1,198 @@
+module SketchToolbar = {
+ @module("./SketchToolbar.jsx") @react.component
+ external make: (
+ ~state: UseSketch.sketchState,
+ ~onSetTool: UseSketch.tool => unit,
+ ~onSetColor: UseSketch.color => unit,
+ ~onClear: unit => unit,
+ ~onSave: unit => unit,
+ ~onClose: unit => unit,
+ ) => React.element = "default"
+}
+
+type point = {x: float, y: float}
+
+@val external document: {..} = "document"
+external asCanvas: Dom.element => {..} = "%identity"
+
+@react.component
+let make = (
+ ~isActive: bool,
+ ~tool: UseSketch.tool,
+ ~color: UseSketch.color,
+ ~onSave: unit => unit,
+ ~onClear: unit => unit,
+ ~onClose: unit => unit,
+ ~onSetTool: UseSketch.tool => unit,
+ ~onSetColor: UseSketch.color => unit,
+) => {
+ let canvasRef: React.ref> = React.useRef(Nullable.null)
+ let isDrawing = React.useRef(false)
+ let lastPoint = React.useRef(None)
+
+ React.useEffect1(() => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let parent = canvas["parentElement"]
+ canvas["width"] = parent["offsetWidth"]
+ canvas["height"] = parent["offsetHeight"]
+ }
+ None
+ }, [isActive])
+
+ let getPos = (e: JsxEvent.Mouse.t, canvas) => {
+ let rect = canvas["getBoundingClientRect"]()
+ {
+ x: Float.fromInt(JsxEvent.Mouse.clientX(e)) -. rect["left"],
+ y: Float.fromInt(JsxEvent.Mouse.clientY(e)) -. rect["top"],
+ }
+ }
+
+ let startDraw = (e: JsxEvent.Mouse.t) => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ isDrawing.current = true
+ let pos = getPos(e, canvas)
+ lastPoint.current = Some(pos)
+ if tool === UseSketch.Highlighter {
+ let ctx = canvas["getContext"]("2d")
+ let hex = UseSketch.colorToHex(color)
+ let width = UseSketch.toolToWidth(tool, 2.0)
+ ctx["globalAlpha"] = 0.25
+ ctx["globalCompositeOperation"] = "source-over"
+ ctx["strokeStyle"] = hex
+ ctx["lineWidth"] = width
+ ctx["lineCap"] = "round"
+ ctx["lineJoin"] = "round"
+ let _ = ctx["beginPath"]()
+ let _ = ctx["moveTo"](pos.x, pos.y)
+ } else if tool === UseSketch.Text {
+ let text = canvas["ownerDocument"]["defaultView"]["prompt"]("Enter text:", "")
+ switch Nullable.toOption(text) {
+ | None => ()
+ | Some(t) =>
+ let ctx = canvas["getContext"]("2d")
+ let hex = UseSketch.colorToHex(color)
+ ctx["globalAlpha"] = 1.0
+ ctx["fillStyle"] = hex
+ ctx["font"] = "bold 16px sans-serif"
+ let _ = ctx["fillText"](t, pos.x, pos.y)
+ }
+ }
+ }
+ }
+
+ let draw = (e: JsxEvent.Mouse.t) => {
+ if isDrawing.current && tool !== UseSketch.Text {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ switch lastPoint.current {
+ | None => ()
+ | Some(last) =>
+ let ctx = canvas["getContext"]("2d")
+ let pos = getPos(e, canvas)
+ let hex = UseSketch.colorToHex(color)
+ let width = UseSketch.toolToWidth(tool, 2.0)
+ switch tool {
+ | UseSketch.Highlighter =>
+ let _ = ctx["lineTo"](pos.x, pos.y)
+ let _ = ctx["stroke"]()
+ | _ =>
+ ctx["globalAlpha"] = 1.0
+ ctx["strokeStyle"] = hex
+ ctx["lineWidth"] = width
+ ctx["lineCap"] = "round"
+ ctx["lineJoin"] = "round"
+ let _ = ctx["beginPath"]()
+ let _ = ctx["moveTo"](last.x, last.y)
+ let _ = ctx["quadraticCurveTo"](
+ last.x +. (pos.x -. last.x) /. 2.0,
+ last.y +. (pos.y -. last.y) /. 2.0,
+ pos.x,
+ pos.y,
+ )
+ let _ = ctx["stroke"]()
+ }
+ lastPoint.current = Some(pos)
+ }
+ }
+ }
+ }
+
+ let stopDraw = (_e: JsxEvent.Mouse.t) => {
+ if tool === UseSketch.Highlighter {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let ctx = canvas["getContext"]("2d")
+ let _ = ctx["stroke"]()
+ ctx["globalAlpha"] = 1.0
+ ctx["globalCompositeOperation"] = "source-over"
+ }
+ }
+ isDrawing.current = false
+ lastPoint.current = None
+ }
+
+ let handleClear = () => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let ctx = canvas["getContext"]("2d")
+ let _ = ctx["clearRect"](0, 0, canvas["width"], canvas["height"])
+ onClear()
+ }
+ }
+
+ let handleSave = () => {
+ switch canvasRef.current->Nullable.toOption {
+ | None => ()
+ | Some(el) =>
+ let canvas = asCanvas(el)
+ let dataUrl = canvas["toDataURL"]("image/png")
+ let link = document["createElement"]("a")
+ link["href"] = dataUrl
+ link["download"] = "sketch.png"
+ let _ = link["click"]()
+ onSave()
+ }
+ }
+
+ if !isActive {
+ React.null
+ } else {
+
+ );
+}
diff --git a/apps/web/src/components/generic/AssetLoader.jsx b/apps/web/src/components/generic/AssetLoader.jsx
new file mode 100644
index 0000000..4d871eb
--- /dev/null
+++ b/apps/web/src/components/generic/AssetLoader.jsx
@@ -0,0 +1,10 @@
+// components/generic/AssetPageLoader.jsx
+import { Loader2 } from "lucide-react";
+
+export default function AssetPageLoader() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/generic/AssetPickerSheet.jsx b/apps/web/src/components/generic/AssetPickerSheet.jsx
new file mode 100644
index 0000000..67cee09
--- /dev/null
+++ b/apps/web/src/components/generic/AssetPickerSheet.jsx
@@ -0,0 +1,409 @@
+import { useEffect, useState, useCallback, useRef, memo } from "react";
+import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
+
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Spinner } from "@/components/ui/spinner";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+
+import { useAssets, useMediaTokens } from "@/contexts/AdminAssetsContext";
+import { formatPlayerTime } from "@/utils/format.util";
+
+const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
+const DEBOUNCE_MS = 400;
+
+const EXT_OPTIONS = {
+ image: ["svg", "png", "jpg", "jpeg", "webp", "gif"],
+ video: ["mp4", "mov", "webm", "avi"],
+ audio: ["mp3", "wav", "ogg", "m4a"],
+ document: ["pdf", "docx", "xlsx", "pptx"],
+};
+
+// ─── Asset Card ───────────────────────────────────────────────────────────────
+// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
+// memo()'d because a sibling AssetPickerSheet's mediaTokens update re-runs
+// this component's own function body (context change) even when none of
+// THIS card's actual props changed — memo + a stable onSelect (see
+// handleSelect's useCallback below) lets it bail out instead of re-rendering
+// every card in every mounted-but-untouched picker.
+
+const AssetCard = memo(function AssetCard({ asset, streamSrc, selected, onSelect }) {
+ const directThumb = asset.thumbnail_url ?? asset.file_url;
+ const thumb = streamSrc ?? directThumb;
+ // duration comes straight off the asset row (ffprobe-derived at upload) —
+ // shows regardless of whether a thumbnail image loaded, since a missing
+ // preview shouldn't also mean losing the one other useful signal at a glance.
+ const showDuration = (asset.file_type === "video" || asset.file_type === "audio") && !!asset.duration;
+
+ return (
+
+ );
+});
+
+function EmptyState({ fileType }) {
+ return (
+
+
No {fileType} files found.
+
+ );
+}
+
+// ─── Main Sheet ───────────────────────────────────────────────────────────────
+
+// allowedExtensions optionally narrows a fileType's picker to a subset of
+// EXT_OPTIONS (e.g. the Document Import block only wants pdf/pptx out of the
+// full document set). Omit it and behavior is unchanged from every existing
+// caller — the extension filter stays purely an opt-in user-facing toggle.
+export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allowedExtensions }) {
+ // NOTE: previously this returned null before any hooks ran when `open` was
+ // false. Since the parent renders this component unconditionally (only the
+ // `open` prop toggles), that meant React remounted every hook from scratch
+ // on each open — wiping local state and forcing a full refetch every time,
+ // plus skipping the close transition. Visibility is controlled by
+ // below instead, so state survives across open/close
+ // toggles. Because this component now stays mounted for its parent's
+ // lifetime, a page with several media blocks keeps several instances
+ // mounted at once — assets/pagination/loading are therefore local state
+ // (below), not AdminAssetsContext state, so opening one picker doesn't
+ // re-render or stomp the list of every other mounted one. mediaTokens and
+ // the request-level TTL cache stay in context — they're pure per-asset /
+ // per-query caches, safe (and worth) sharing across instances.
+
+ const { fetchAssetsList } = useAssets();
+ // Separate context from useAssets() on purpose — see useMediaTokens's
+ // definition in AdminAssetsContext.jsx. Keeps this picker from
+ // re-rendering when the admin Assets table's unrelated list state changes.
+ const { mediaTokens, getMediaTokens } = useMediaTokens();
+
+ const [search, setSearch] = useState("");
+ const [activeExts, setActiveExts] = useState(new Set());
+ const [page, setPage] = useState(1);
+ const [selected, setSelected] = useState(null);
+ const [filterOpen, setFilterOpen] = useState(false);
+
+ // Local, per-instance list state — deliberately NOT shared context state.
+ // A page can mount many AssetPickerSheet instances at once (one per media
+ // block); sharing a single assets/pagination/loading slice meant opening
+ // one picker re-rendered and stomped the list of every other mounted one.
+ const [assets, setAssets] = useState([]);
+ const [pagination, setPagination] = useState({ page: 1, totalPages: 0, hasPrevPage: false, hasNextPage: false });
+ const [loading, setLoading] = useState(false);
+ // Tracks the follow-up batch token fetch separately from `loading` (the
+ // list fetch) — combined into `showSpinner` below so the grid never
+ // renders with placeholder "No preview" cards that then pop thumbnails
+ // in a beat later. One spinner, then everything appears already loaded.
+ const [tokensLoading, setTokensLoading] = useState(false);
+
+ const debounceRef = useRef(null);
+ const isFirstSearchRun = useRef(true);
+ const LIMIT = 10;
+
+ const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
+
+ const resolveStreamSrc = useCallback((assetId) => {
+ const entry = mediaTokens[String(assetId)];
+ if (!entry) return null;
+ return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`;
+ }, [mediaTokens]);
+
+ // ── Build and fire fetch ──────────────────────────────────────────────────
+ const doFetch = useCallback((searchVal, extSet, pg) => {
+ // No chips picked: fall back to allowedExtensions (if the caller
+ // passed one) as a mandatory whitelist, otherwise no extension
+ // filter at all — matches every pre-existing caller's behavior.
+ const extFilterValue = extSet.size > 0 ? [...extSet] : (allowedExtensions ?? null);
+ const filters = [
+ ...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
+ ...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
+ ...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
+ ];
+ setLoading(true);
+ fetchAssetsList({ page: pg, limit: LIMIT, filters })
+ .then(({ assets: nextAssets, pagination: nextPagination }) => {
+ setAssets(nextAssets);
+ setPagination(nextPagination);
+ })
+ .finally(() => setLoading(false));
+ }, [fileType, fetchAssetsList, allowedExtensions]);
+
+ // ── Immediate fetch: on open, or when filters/page change while open ──────
+ // (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
+ // with the same query within the cache window costs no network round-trip.)
+ useEffect(() => {
+ if (!open) return;
+ doFetch(search, activeExts, page);
+ }, [open, activeExts, page]);
+
+ // ── Debounced fetch: only when the user edits the search box ──────────────
+ // Deliberately does NOT depend on `open` — otherwise this and the effect
+ // above both fire on every sheet open, doubling the request.
+ useEffect(() => {
+ if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; }
+ if (!open) return;
+ clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ setPage(1);
+ doFetch(search, activeExts, 1);
+ }, DEBOUNCE_MS);
+ return () => clearTimeout(debounceRef.current);
+ }, [search]);
+
+ // ── Batch token fetch after assets load ───────────────────────────────────
+ // One request for all S3 assets on the current page instead of N per-card
+ // requests. getMediaTokens (AdminAssetsContext) already skips any asset_id
+ // whose cached token is still valid, so reopening the sheet within the
+ // token's ~30min TTL issues no request at all for previously-seen assets.
+ useEffect(() => {
+ if (!open || !assets.length) return;
+
+ const s3Ids = assets
+ .filter((a) => a.storage_provider === "s3")
+ .map((a) => a.asset_id);
+
+ if (!s3Ids.length) return;
+
+ setTokensLoading(true);
+ getMediaTokens(s3Ids)
+ .catch((err) => {
+ console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
+ })
+ .finally(() => setTokensLoading(false));
+ }, [assets, open, getMediaTokens]);
+
+ // ── Reset on close ────────────────────────────────────────────────────────
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setActiveExts(new Set());
+ setPage(1);
+ setSelected(null);
+ setFilterOpen(false);
+ }
+ }, [open]);
+
+ const toggleExt = (ext) => {
+ setActiveExts((prev) => {
+ const next = new Set(prev);
+ next.has(ext) ? next.delete(ext) : next.add(ext);
+ return next;
+ });
+ setPage(1);
+ };
+
+ const clearFilters = () => {
+ setActiveExts(new Set());
+ setPage(1);
+ };
+
+ // useCallback so AssetCard's memo() (above) actually has a stable prop to
+ // compare against — an inline function here would give every card a new
+ // onSelect reference on every render, defeating the memo entirely.
+ const handleSelect = useCallback((asset) => {
+ setSelected(asset.asset_id);
+ // Pass the resolved stream/presigned URL as a second arg so callers
+ // (e.g. badge image picker) can use the authenticated URL directly
+ // rather than falling back to asset.file_url which is a private CDN
+ // key that the browser cannot load without S3 credentials.
+ const resolvedUrl = resolveStreamSrc(asset.asset_id)
+ ?? asset.thumbnail_url
+ ?? asset.file_url
+ ?? null;
+ onSelect(asset, resolvedUrl);
+ onOpenChange(false);
+ }, [resolveStreamSrc, onSelect, onOpenChange]);
+
+ const label = fileType
+ ? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
+ : "Files";
+
+ const hasActiveFilters = activeExts.size > 0;
+ const showSpinner = loading || tokensLoading;
+
+ return (
+
+
+
+ {/* ── Header ── */}
+
+ Select {label}
+ Click a file to attach it.
+
+
+ {/* ── Search + Filter ── */}
+
+
+ {/* ── Grid ── */}
+ {/* showSpinner covers both the list fetch AND the follow-up batch
+ token fetch, so the grid only ever appears once every card
+ already has its thumbnail resolved — no placeholder-then-pop-in
+ flicker. Spinner fills the full remaining sheet height (not a
+ small fixed box) so it's centered in the whole visible area. */}
+
+ {showSpinner ? (
+
+
+
+ ) : !assets.length ? (
+
+ ) : (
+
+ {assets.map((asset) => (
+
+ ))}
+
+ )}
+
+
+ {/* ── Pagination ── */}
+ {/* Always shown once there's at least one page of results (even a
+ single page) — Prev/Next self-disable via hasPrevPage/hasNextPage,
+ so a one-page list just reads "Page 1 of 1" with both disabled
+ rather than hiding the control entirely. */}
+ {pagination.totalPages > 0 && (
+
+
+ Page {pagination.page} of {pagination.totalPages}
+
+
+ ) : (
+
+ )}
+
+ {/* ── Metadata fields (admin only) ─────────────────────────────────
+ These fields are saved into block content and rendered directly
+ by the client AudioBlock — no extra API call needed at runtime. */}
+ {!readOnly && src && (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/generic/Blocks/Admin/VideoBlock.jsx b/apps/web/src/components/generic/Blocks/Admin/VideoBlock.jsx
new file mode 100644
index 0000000..417a50a
--- /dev/null
+++ b/apps/web/src/components/generic/Blocks/Admin/VideoBlock.jsx
@@ -0,0 +1,502 @@
+import { useRef, useState, useEffect, useCallback } from "react";
+import {
+ Play,
+ Pause,
+ SkipBack,
+ Volume2,
+ VolumeX,
+ Maximize2,
+ Minimize2,
+ VideoIcon,
+} from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { AssetPickerSheet } from "../../AssetPickerSheet";
+import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
+import { MediaFallback } from "@/components/generic/MediaFallback";
+import { Spinner } from "@/components/ui/spinner";
+import { formatPlayerTime as fmtTime } from "@/utils/format.util";
+
+// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
+
+export function VideoBlock({ content, onUpdate, readOnly = false }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ // content.url is redacted (null) server-side for S3 assets — see
+ // redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
+ // through media.util.js instead of trusting the persisted url/thumbnail.
+ //
+ // thumbnail_url is intentionally NOT passed here: resolveAssetSrc()'s fast
+ // path falls back to thumbnail_url when there's no stream_token, which for
+ // a video asset with a redacted (null) file_url meant `src` resolved to
+ // the poster JPEG instead of the video file, and the browser then refused
+ // to play it ("media resource ... was not suitable"). Omitting it forces
+ // the async mint-token path, which resolves the real video stream src.
+ const { src, loading } = useAssetPreviewSrc(
+ { asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url },
+ { scope: "admin" },
+ );
+ const poster = content?.thumbnail_url ?? undefined;
+
+ const vidRef = useRef(null);
+ const wrapRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [totalDuration, setTotalDuration] = useState(0);
+ const [muted, setMuted] = useState(false);
+ const [volume, setVolume] = useState(1);
+ const [overlayVisible,setOverlayVisible]= useState(true);
+ // Native Fullscreen API works on desktop (all browsers) and Android Chrome.
+ // iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
+ // pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
+ // custom controls instead of falling back to native