mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
merge new_starr_app@qas history into apps/web
git-subtree-dir: apps/web git-subtree-mainline:eaa6d2276agit-subtree-split:459af9d46a
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
src
|
||||||
|
public
|
||||||
|
*.config.js
|
||||||
|
*.config.ts
|
||||||
|
index.html
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# new_starr_app — Frontend
|
||||||
|
|
||||||
|
React + Vite CSR frontend for the STARR LMS platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Metadata
|
||||||
|
|
||||||
|
Metadata (`<title>`, Open Graph, Twitter Card) is managed with **`react-helmet-async`**.
|
||||||
|
The system has two layers:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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 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 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>
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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" />
|
||||||
|
<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&family=Fira+Code:wght@400..600&family=Abril+Fatface&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>%VITE_APP_NAME%</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.
@@ -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.
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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.
@@ -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.
@@ -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,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.
@@ -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 @@
|
|||||||
|
967186
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@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",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"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-phone-number-input": "^3.4.17",
|
||||||
|
"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",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx-js-style": "^1.2.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@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",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"postcss": "^8.5.13",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7880
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
msw: true
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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("#fill-color-gradient-render-6-0");"/></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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user