ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
src
public
*.config.js
*.config.ts
index.html
+26
View File
@@ -0,0 +1,26 @@
# ═══════════════════════════════════════════════════════════════════════════════
# $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
# ── 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
+36 -14
View File
@@ -1,26 +1,48 @@
# Logs
logs
# ── 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*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
yarn-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# ── OS ────────────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# Editor directories and files
# ── Editors ───────────────────────────────────────────────────────────────────
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
.idea/
*.swp
*.swo
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
View File
+9
View File
@@ -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;"]
+170 -9
View File
@@ -1,16 +1,177 @@
# React + Vite
# new_starr_app — Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
React + Vite CSR frontend for the STARR LMS platform.
Currently, two official plugins are available:
---
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## Page Metadata
## React Compiler
Metadata (`<title>`, Open Graph, Twitter Card) is managed with **`react-helmet-async`**.
The system has two layers:
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
1. **Global fallback** — a `<Helmet>` 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 `<PageMeta />` component anywhere in its JSX tree. react-helmet-async lets the deepest/last `<Helmet>` win, so the page always overrides the fallback.
## Expanding the ESLint configuration
This approach fixes the `useNavigate()` stale-title bug: when you navigate away from a page, its `<PageMeta>` unmounts and the global fallback immediately takes over.
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
---
### 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 (
<section className="bg-muted/60 h-full">
<PageMeta
title="Courses - STARR"
description="Browse and manage your training courses."
/>
{/* rest of page */}
</section>
)
}
```
---
### 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 (
<section className="bg-muted/60 min-h-full">
{/* undefined while course is null → global default renders */}
<PageMeta
title={course ? `${course.title} - STARR` : undefined}
description={course?.description}
/>
{/* rest of page */}
</section>
)
}
```
---
### 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 (
<section className="bg-muted/60 h-full">
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
{/* rest of page */}
</section>
)
}
```
---
### Placement rules
- Drop `<PageMeta>` 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 `<PageMeta>` — it is an inline element, not a wrapper.
```jsx
// CORRECT — inline inside root element
return (
<section>
<PageMeta title="..." />
<div>...</div>
</section>
)
// WRONG — do not use as a wrapper
return (
<PageMeta title="...">
<section>...</section>
</PageMeta>
)
```
---
### 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 `<PageMeta title="..." />` 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 `<PageMeta {...value} />` and then its children. It works but should not be used for new pages — use the inline `<PageMeta />` pattern instead.
```jsx
// Old pattern (Login / Register only — do not copy for new pages)
<MetadataProvider value={{ title: 'Login - Philproperties', description: '...' }}>
<LoginContent />
</MetadataProvider>
// New pattern (use this for all new pages)
<section>
<PageMeta title="Login - Philproperties" description="..." />
<LoginContent />
</section>
```
+5 -3
View File
@@ -12,6 +12,8 @@
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -19,7 +21,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@coss": "https://coss.com/ui/r/{name}.json"
}
}
+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-app</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
<title>%VITE_APP_NAME%</title>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -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}
View File
+8
View File
@@ -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"
}
@@ -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 */
@@ -0,0 +1,43 @@
@react.component
let make = (
~lesson: AnnotationTypes.lesson,
~note: option<string>,
~onNoteChange: (string, string) => unit,
~onClose: unit => unit,
) => {
let noteValue = switch note {
| Some(n) => n
| None => ""
}
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{React.string("Annotation")}
</p>
<h2 className="text-lg font-bold mt-0.5">
{React.string(lesson.title)}
</h2>
</div>
<button
onClick={_ => onClose()}
className="text-muted-foreground hover:text-foreground transition-colors text-sm"
>
{React.string("✕ Close")}
</button>
</div>
<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 = ReactEvent.Form.target(e)["value"]
onNoteChange(lesson.id, value)
}}
/>
<p className="text-xs text-muted-foreground mt-2">
{React.string("Auto-saved to your browser")}
</p>
</div>
}
@@ -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 */
@@ -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<string> = localStorage["getItem"](key)
Nullable.toOption(value)
}
let deleteNote = (courseId: string, lessonId: string) => {
let key = storageKey(courseId, lessonId)
localStorage["removeItem"](key)
}
@@ -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 */
@@ -0,0 +1,17 @@
type lesson = {
id: string,
title: string,
unitId: string,
}
type annotationState = {
selectedLesson: option<lesson>,
isPenOpen: bool,
notes: dict<string>,
}
let initialState = {
selectedLesson: None,
isPenOpen: false,
notes: Dict.make(),
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 */
@@ -0,0 +1,4 @@
@react.component
let make = () => {
<div> {React.string("ReScript is working!")} </div>
}
@@ -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 */
@@ -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<Nullable.t<Dom.element>> = 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 {
<div className="absolute inset-0 z-10" style={{pointerEvents: isActive ? "all" : "none"}}>
<SketchToolbar.make
state={{
UseSketch.isActive,
tool,
color,
strokeWidth: 2.0,
}}
onSetTool={onSetTool}
onSetColor={onSetColor}
onClear={handleClear}
onSave={handleSave}
onClose={onClose}
/>
<canvas
ref={canvasRef->ReactDOM.Ref.domRef}
width="100%"
height="100%"
className="w-full h-full cursor-crosshair"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
/>
</div>
}
}
@@ -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 */
@@ -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"
}
@@ -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 */
@@ -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)
}
@@ -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 */
@@ -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)
}
+43
View File
@@ -0,0 +1,43 @@
@react.component
let make = (
~lesson: AnnotationTypes.lesson,
~note: option<string>,
~onNoteChange: (string, string) => unit,
~onClose: unit => unit,
) => {
let noteValue = switch note {
| Some(n) => n
| None => ""
}
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{React.string("Annotation")}
</p>
<h2 className="text-lg font-bold mt-0.5">
{React.string(lesson.title)}
</h2>
</div>
<button
onClick={_ => onClose()}
className="text-muted-foreground hover:text-foreground transition-colors text-sm"
>
{React.string("✕ Close")}
</button>
</div>
<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 = ReactEvent.Form.target(e)["value"]
onNoteChange(lesson.id, value)
}}
/>
<p className="text-xs text-muted-foreground mt-2">
{React.string("Auto-saved to your browser")}
</p>
</div>
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -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<string> = localStorage["getItem"](key)
Nullable.toOption(value)
}
let deleteNote = (courseId: string, lessonId: string) => {
let key = storageKey(courseId, lessonId)
localStorage["removeItem"](key)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
type lesson = {
id: string,
title: string,
unitId: string,
}
type annotationState = {
selectedLesson: option<lesson>,
isPenOpen: bool,
notes: dict<string>,
}
let initialState = {
selectedLesson: None,
isPenOpen: false,
notes: Dict.make(),
}
+4
View File
@@ -0,0 +1,4 @@
@react.component
let make = () => {
<div> {React.string("ReScript is working!")} </div>
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+198
View File
@@ -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<Nullable.t<Dom.element>> = 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 {
<div className="absolute inset-0 z-10" style={{pointerEvents: isActive ? "all" : "none"}}>
<SketchToolbar.make
state={{
UseSketch.isActive,
tool,
color,
strokeWidth: 2.0,
}}
onSetTool={onSetTool}
onSetColor={onSetColor}
onClear={handleClear}
onSave={handleSave}
onClose={onClose}
/>
<canvas
ref={canvasRef->ReactDOM.Ref.domRef}
width="100%"
height="100%"
className="w-full h-full cursor-crosshair"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
/>
</div>
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -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"
}
+38
View File
@@ -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)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+67
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
967186
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — all unknown paths go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed assets aggressively
location ~* \.(js|css|woff2?|ttf|svg|ico|png|jpg|jpeg|webp|gif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# No caching for index.html so users always get the latest shell
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
+8
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.11.0",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -27,16 +28,22 @@
"lucide-react": "^1.14.0",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.0.227",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.5",
"react-easy-crop": "^6.0.2",
"react-helmet-async": "^3.0.0",
"react-hook-form": "^7.75.0",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-resizable-panels": "^4.11.0",
"react-router-dom": "^7.14.2",
"recharts": "3.8.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -51,6 +58,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
+1123
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
msw: true
@@ -0,0 +1 @@
<svg width="24" xmlns="http://www.w3.org/2000/svg" height="24" id="screenshot-47f7117a-c919-8034-8008-1e04fe684930" viewBox="0 0 24 24" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-47f7117a-c919-8034-8008-1e04fe684930" width="512" height="512" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-47f7117a-c919-8034-8008-1e04fe6909e4" style="display: none;"><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fe6909e4"><rect width="24" height="24" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-47f7117a-c919-8034-8008-1e04fe6a2f4e"><defs><linearGradient id="fill-color-gradient-render-6-0" x1="0.49559909221913717" y1="0.29963732228022955" x2="0.49559909221913717" y2="1.693574264042768" gradientTransform=""><stop offset="0" stop-color="#0ac588" stop-opacity="1"/><stop offset="1" stop-color="#3658bd" stop-opacity="0.5"/></linearGradient><pattern patternUnits="userSpaceOnUse" x="0.020120959611631406" y="0.019498997214896008" width="23.97987904038837" height="23.96200175500084" patternTransform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" id="fill-0-render-6"><g><rect width="23.97987904038837" height="23.96200175500084" style="fill: url(&quot;#fill-color-gradient-render-6-0&quot;);"/></g></pattern></defs><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fe6a2f4e"><path d="M24,12C24,10.373992919921875,23.2860107421875,8.839996337890625,22.074951171875,7.876007080078125C22.2149658203125,6.253997802734375,21.635009765625,4.6649932861328125,20.4849853515625,3.514007568359375C19.3349609375,2.3650054931640625,17.75,1.7830047607421875,16.2080078125,1.9589996337890625C14.1939697265625,-0.5970001220703125,9.843017578125,-0.6450042724609375,7.8759765625,1.9239959716796875C4.6429443359375,1.5399932861328125,1.5340576171875,4.5839996337890625,1.9599609375,7.7919921875C-0.595947265625,9.805999755859375,-0.64501953125,14.156997680664062,1.925048828125,16.125C1.7850341796875,17.746994018554688,2.364990234375,19.33599853515625,3.5150146484375,20.48699951171875C4.6650390625,21.636001586914062,6.251953125,22.218002319335938,7.7919921875,22.0419921875C9.8060302734375,24.597991943359375,14.156982421875,24.64599609375,16.1240234375,22.076995849609375C17.7440185546875,22.21600341796875,19.333984375,21.63800048828125,20.4849853515625,20.48699951171875C21.634033203125,19.337005615234375,22.2139892578125,17.746994018554688,22.0400390625,16.209991455078125C23.2860107421875,15.162002563476562,24,13.628005981445312,24,12.001998901367188ZM8,9C8.0059814453125,7.6920013427734375,9.9940185546875,7.6920013427734375,10,9C9.9940185546875,10.307998657226562,8.0059814453125,10.307998657226562,8,9ZM10.83203125,15.55499267578125C10.5240478515625,16.01800537109375,9.8990478515625,16.136001586914062,9.4449462890625,15.832000732421875C8.9849853515625,15.5260009765625,8.8609619140625,14.904998779296875,9.16796875,14.44500732421875L13.16796875,8.44500732421875C13.4739990234375,7.985992431640625,14.093994140625,7.8600006103515625,14.5550537109375,8.167999267578125C15.0150146484375,8.4739990234375,15.1390380859375,9.095001220703125,14.83203125,9.55499267578125L10.83203125,15.55499267578125ZM15,16C13.6920166015625,15.994003295898438,13.6920166015625,14.005996704101562,15,14C16.3079833984375,14.005996704101562,16.3079833984375,15.994003295898438,15,16Z" fill="url(#fill-0-render-6)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1 @@
<svg width="24" xmlns="http://www.w3.org/2000/svg" height="24" id="screenshot-47f7117a-c919-8034-8008-1e04fdf8b719" viewBox="0 0 24 24" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-47f7117a-c919-8034-8008-1e04fdf8b719" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-47f7117a-c919-8034-8008-1e04fdfa136f" style="display: none;"><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fdfa136f"><rect width="24" height="24" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-47f7117a-c919-8034-8008-1e04fdfc23d0"><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fdfc23d0"><path d="M15,12C15,13.302001953125,14.1610107421875,14.402008056640625,13,14.815994262695312L13,11C13,10.447998046875,12.552001953125,10,12,10C11.447998046875,10,11,10.447998046875,11,11L11,14.815994262695312C9.8389892578125,14.402008056640625,9,13.302001953125,9,12C9,10.3699951171875,10.6729736328125,8.126007080078125,11.8609619140625,7.0540008544921875C11.9010009765625,7.01800537109375,11.9510498046875,7,12,7C12.050048828125,7,12.0989990234375,7.01800537109375,12.1390380859375,7.0529937744140625C13.3270263671875,8.126007080078125,15,10.369003295898438,15,12ZM22.041015625,16.2080078125C22.2149658203125,17.7449951171875,21.635986328125,19.334991455078125,20.4859619140625,20.485000610351562C19.3360595703125,21.634994506835938,17.7449951171875,22.212997436523438,16.1240234375,22.074996948242188C15.1610107421875,23.285003662109375,13.626953125,24,12,24C10.373046875,24,8.8399658203125,23.285995483398438,7.7919921875,22.041000366210938C6.260986328125,22.220001220703125,4.666015625,21.636001586914062,3.5150146484375,20.485992431640625C2.364013671875,19.33599853515625,1.7860107421875,17.746002197265625,1.925048828125,16.123992919921875C0.7149658203125,15.160995483398438,0,13.626998901367188,0,12C0,10.373001098632812,0.7139892578125,8.839996337890625,1.958984375,7.7919921875C1.7850341796875,6.2550048828125,2.364013671875,4.6649932861328125,3.5140380859375,3.5149993896484375C4.6629638671875,2.3650054931640625,6.251953125,1.7819976806640625,7.8759765625,1.9250030517578125C8.8389892578125,0.714996337890625,10.373046875,0,12,0C13.626953125,0,15.1600341796875,0.7140045166015625,16.2080078125,1.9589996337890625C17.7430419921875,1.787994384765625,19.333984375,2.3639984130859375,20.4849853515625,3.514007568359375C21.635009765625,4.66400146484375,22.2139892578125,6.253997802734375,22.074951171875,7.876007080078125C23.2850341796875,8.839004516601562,24,10.373001098632812,24,12C24,13.626998901367188,23.2860107421875,15.160003662109375,22.041015625,16.2080078125ZM17,12C17,9.427001953125,14.697021484375,6.667999267578125,13.47802734375,5.5679931640625C12.635009765625,4.8079986572265625,11.364013671875,4.8070068359375,10.52099609375,5.5679931640625C9.302978515625,6.6669921875,7,9.425994873046875,7,11.998992919921875C7,14.412994384765625,8.720947265625,16.432998657226562,11,16.897994995117188L11,17.998992919921875C11,18.550994873046875,11.447998046875,18.998992919921875,12,18.998992919921875C12.552001953125,18.998992919921875,13,18.550994873046875,13,17.998992919921875L13,16.897994995117188C15.279052734375,16.432998657226562,17,14.41400146484375,17,11.998992919921875Z" style="fill: rgb(136, 184, 48); fill-opacity: 1;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1 @@
<svg width="24" xmlns="http://www.w3.org/2000/svg" height="24.001" id="screenshot-47f7117a-c919-8034-8008-1e04fe58b8bc" viewBox="0 0 24 24.001" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-47f7117a-c919-8034-8008-1e04fe58b8bc" width="512" height="512" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-47f7117a-c919-8034-8008-1e04fe599a57" style="display: none;"><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fe599a57"><rect width="24" height="24.00000000000003" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0.0010000000474974513"/></g></g><g id="shape-47f7117a-c919-8034-8008-1e04fe5aa3d6"><defs><linearGradient id="fill-color-gradient-render-12-0" x1="0.515402781361369" y1="-0.07826510690515463" x2="0.552544898749403" y2="1.2304600216950337" gradientTransform=""><stop offset="0" stop-color="#1e85fb" stop-opacity="1"/><stop offset="1" stop-color="#db106f" stop-opacity="0.5"/></linearGradient><pattern patternUnits="userSpaceOnUse" x="1" y="0" width="22" height="24.000000549363932" patternTransform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" id="fill-0-render-12"><g><rect width="22" height="24.000000549363932" style="fill: url(&quot;#fill-color-gradient-render-12-0&quot;);"/></g></pattern></defs><g class="fills" id="fills-47f7117a-c919-8034-8008-1e04fe5aa3d6"><path d="M14,12.001007080078125C14,13.10400390625,13.10302734375,14.001007080078125,12,14.001007080078125C10.89697265625,14.001007080078125,10,13.10400390625,10,12.001007080078125C10,10.89801025390625,10.89697265625,10.001007080078125,12,10.001007080078125C13.10302734375,10.001007080078125,14,10.89801025390625,14,12.001007080078125ZM23,17.001007080078125C23,18.10400390625,22.10302734375,19.001007080078125,21,19.001007080078125C20.262939453125,19.001007080078125,19.625,18.59600830078125,19.2779541015625,18.001007080078125L16.1309814453125,18.001007080078125C15.6929931640625,18.001007080078125,15.2869873046875,18.23699951171875,15.071044921875,18.61700439453125L13.719970703125,20.99700927734375C13.89404296875,21.292999267578125,14,21.633010864257812,14,22C14,23.103012084960938,13.10302734375,24,12,24C10.89697265625,24,10,23.103012084960938,10,22C10,21.633010864257812,10.1070556640625,21.292999267578125,10.280029296875,20.99700927734375L8.928955078125,18.61700439453125C8.7130126953125,18.236007690429688,8.3070068359375,18,7.8690185546875,18L4.7220458984375,18C4.375,18.595001220703125,3.737060546875,19,3,19C1.89697265625,19,1,18.103012084960938,1,17C1,15.897003173828125,1.89697265625,15,3,15C3.1510009765625,15,3.2969970703125,15.020004272460938,3.43798828125,15.052001953125L4.8289794921875,12.602005004882812C5.0400390625,12.231002807617188,5.0400390625,11.769012451171875,4.8289794921875,11.39801025390625L3.43798828125,8.948013305664062C3.2969970703125,8.980010986328125,3.1510009765625,9,3,9C1.89697265625,9,1,8.103012084960938,1,7C1,5.897003173828125,1.89697265625,5,3,5C3.737060546875,5,4.375,5.4050140380859375,4.7220458984375,6L7.8690185546875,6C8.3070068359375,6,8.7130126953125,5.764007568359375,8.928955078125,5.3830108642578125L10.280029296875,3.0040130615234375C10.1070556640625,2.7080078125,10,2.368011474609375,10,2C10,0.897003173828125,10.89697265625,0,12,0C13.10302734375,0,14,0.897003173828125,14,2C14,2.36700439453125,13.8929443359375,2.707000732421875,13.719970703125,3.0040130615234375L15.071044921875,5.3830108642578125C15.2869873046875,5.764007568359375,15.6929931640625,6,16.1309814453125,6L19.2779541015625,6C19.625,5.4050140380859375,20.262939453125,5,21,5C22.10302734375,5,23,5.897003173828125,23,7C23,8.103012084960938,22.10302734375,9,21,9C20.8489990234375,9,20.7030029296875,8.980010986328125,20.56201171875,8.948013305664062L19.1710205078125,11.39801025390625C18.9599609375,11.770004272460938,18.9610595703125,12.231002807617188,19.1710205078125,12.602005004882812L20.56201171875,15.052001953125C20.7030029296875,15.020004272460938,20.8489990234375,15,21,15C22.10302734375,15,23,15.897003173828125,23,17ZM16,12.001007080078125C16,9.795013427734375,14.2060546875,8.001007080078125,12,8.001007080078125C9.7939453125,8.001007080078125,8,9.795013427734375,8,12.001007080078125C8,14.207000732421875,9.7939453125,16.001007080078125,12,16.001007080078125C14.2060546875,16.001007080078125,16,14.207000732421875,16,12.001007080078125Z" fill="url(#fill-0-render-12)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+53 -12
View File
@@ -1,14 +1,13 @@
import { useEffect } from 'react';
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { HelmetProvider } from "react-helmet-async";
import { Helmet, HelmetProvider } from "react-helmet-async";
import { TooltipProvider } from './components/ui/tooltip';
import { setAuthInterceptor } from './utils/api.util';
import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util';
import AppRouter from './routes/AppRouter';
import './index.css';
import 'react-photo-view/dist/react-photo-view.css';
function AppWithAuth() {
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth()
@@ -16,32 +15,74 @@ function AppWithAuth() {
useEffect(() => {
attachCsrfInterceptor()
fetchCsrfToken()
setAuthInterceptor(
() => accessTokenRef.current, // ← always fresh token
() => accessTokenRef.current,
(newToken) => {
if (newToken) {
setAccessToken(newToken)
setUser(decodeToken(newToken))
// setUser(decodeToken(newToken))
/*****************************************************************************
* REMOVED: setUser(decodeToken(newToken))
*
* WHY: This callback fires on EVERY silent token refresh (401 → /auth/refresh
* → retry), not just on initial login. decodeToken() only returns the raw JWT
* payload — { user_id, email, acc_type, reg_type, iat, exp } — which does NOT
* include personal_info, achievements, or any profile data.
*
* Each silent refresh was overwriting the full user object (originally set by
* login/verifyOTP/restoreSession via safeUser()) with this stripped-down JWT
* payload. After the first refresh, personal_info became undefined, causing:
* - ClientNav to fall back to email instead of full name
* - ROLE_CONFIG lookups to behave inconsistently
* - Any component reading user.personal_info to silently break
*
* SUGGESTION: user state should ONLY be set from actual API responses that return
* safeUser() (login, verifyOTP, restoreSession). Token refresh should update
* ONLY the access token — never touch user. This applies uniformly across
* admin, client, and staff roles since they all share this interceptor.
*
* Basis to see:
* AuthContext.jsx the setUser state for login(), restoreSession()
* auth.controller.js return outputs for login and refreshToken and so safeUser()
*
*
*****************************************************************************/
} else {
logout()
}
}
)
restoreSession() // ← only here, never in route guards
restoreSession()
}, [])
return <AppRouter />
}
{/* if there is revisions then remove Tooltip */ }
export default function App() {
return (
<HelmetProvider>
{/* Global fallback — overridden by any mounted PageMeta */}
<Helmet>
<title>STARR | Philproperties</title>
<meta name="description" content="This is still in development phase. Come back soon." />
<meta name="keywords" content="philproperties, online courses, sales training" />
<meta property="og:title" content="STARR | Philproperties" />
<meta property="og:description" content="This is still in development phase. Come back soon." />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="STARR | Philproperties" />
<meta name="twitter:description" content="This is still in development phase. Come back soon." />
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
</Helmet>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<AuthProvider>
<AppWithAuth />
</AuthProvider>
<TooltipProvider delayDuration={300}>
<AuthProvider>
<AppWithAuth />
</AuthProvider>
</TooltipProvider>
</ThemeProvider>
</HelmetProvider>
);
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cn } from "@/lib/utils";
export function Tabs({ className, ...props }) {
return (
<TabsPrimitive.Root
className={cn(
"flex flex-col gap-2 data-[orientation=vertical]:flex-row",
className,
)}
data-slot="tabs"
{...props}
/>
);
}
export function TabsList({ variant = "default", className, children, ...props }) {
return (
<TabsPrimitive.List
className={cn(
"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground",
"data-[orientation=vertical]:flex-col",
variant === "default"
? "rounded-lg bg-muted p-0.5 text-muted-foreground/72"
: "data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent",
className,
)}
data-slot="tabs-list"
{...props}
>
{children}
<TabsPrimitive.Indicator
className={cn(
"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out",
variant === "underline"
? "z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px"
: "-z-1 rounded-md bg-background shadow-sm/5 dark:bg-input",
)}
data-slot="tab-indicator"
/>
</TabsPrimitive.List>
);
}
export function TabsTab({ className, ...props }) {
return (
<TabsPrimitive.Tab
className={cn(
"relative flex h-9 shrink-0 grow cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-[calc(--spacing(2.5)-1px)] font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:h-8 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0",
className,
)}
data-slot="tabs-tab"
{...props}
/>
);
}
export function TabsPanel({ className, ...props }) {
return (
<TabsPrimitive.Panel
className={cn("flex-1 outline-none", className)}
data-slot="tabs-content"
{...props}
/>
);
}
export { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent };

Some files were not shown because too many files have changed in this diff Show More