Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6e22addb | ||
|
|
25b70c24f1 | ||
|
|
da40422dfa | ||
|
|
e15edafbff | ||
|
|
d5b177aa89 | ||
|
|
d32227ff43 | ||
|
|
7a0d1e93f3 | ||
|
|
560956bbe6 | ||
|
|
7f458dc58d | ||
|
|
361480445f | ||
|
|
57fb23145c | ||
|
|
6207cbc026 | ||
|
|
a149e614a7 | ||
|
|
eab7534ea4 | ||
|
|
79a69c5507 | ||
|
|
70df113ee6 | ||
|
|
44e9a8a29f | ||
|
|
e47cb37ab9 | ||
|
|
02538836a9 | ||
|
|
22398a502b | ||
|
|
e00348ef84 | ||
|
|
8d17ec6583 | ||
|
|
e93a56d753 | ||
|
|
975cea84e3 | ||
|
|
34398a578b | ||
|
|
27efeab796 | ||
|
|
f5ec471318 | ||
|
|
376977a9f7 | ||
|
|
9b9d5c833c | ||
|
|
15448cad6a | ||
|
|
afd734dd61 | ||
|
|
493ef12a9a | ||
|
|
a5935dee0f | ||
|
|
5226f04e86 | ||
|
|
a1fb0597a3 | ||
|
|
a2a2c0a768 | ||
|
|
f2ec6a499f | ||
|
|
0fb81ad772 | ||
|
|
19470c8cd2 | ||
|
|
3f050e5213 | ||
|
|
91c4a2421c | ||
|
|
82d961241e | ||
|
|
f3a60432df | ||
|
|
0701f3b62a | ||
|
|
cf738b9306 | ||
|
|
fcc10c6b31 | ||
|
|
3e96605d4c | ||
|
|
7e35e8b657 | ||
|
|
8de15822fb | ||
|
|
e2099b9002 | ||
|
|
5762eb6a3e | ||
|
|
dfe75390cd | ||
|
|
439ae114f9 | ||
|
|
9b41edb43d | ||
|
|
d87c6758ab | ||
|
|
44fa2badb4 | ||
|
|
0abb5a07e6 | ||
|
|
a9a38ff5dc | ||
|
|
bf70705f1f | ||
|
|
332aa210c4 | ||
|
|
da6a9f2c78 | ||
|
|
4541cf1cdc | ||
|
|
27df724d2a | ||
|
|
bc09430fdf | ||
|
|
e936f93e3a | ||
|
|
3ba566506a | ||
|
|
a7c599b724 | ||
|
|
dbb0b179c3 | ||
|
|
fc634a202d | ||
|
|
7fab23870f | ||
|
|
20a8a3df9d | ||
|
|
e38e37383d | ||
|
|
d45116b2ba | ||
|
|
6ad4f13914 | ||
|
|
2f5d321051 | ||
|
|
57e9c8c487 | ||
|
|
09bc6ec521 | ||
|
|
50885176e0 | ||
|
|
cbeecf6596 | ||
|
|
ee970f2961 | ||
|
|
578a983209 | ||
|
|
617135466d | ||
|
|
fa4c8adf78 | ||
|
|
5b8ab33888 | ||
|
|
0ba44865c7 | ||
|
|
a4999c04af | ||
|
|
2a80e6a1df | ||
|
|
4c8cc5c016 | ||
|
|
d3735ebe27 | ||
|
|
8eab8fdaa0 | ||
|
|
fbb9938af6 | ||
|
|
5080fddf51 | ||
|
|
dfd2c77bc9 | ||
|
|
56c90947e4 | ||
|
|
ae2a1dac12 | ||
|
|
dcf1b28c22 | ||
|
|
f14d8ce693 | ||
|
|
2317a82106 | ||
|
|
a523e13bfd | ||
|
|
1be75240dd | ||
|
|
7275da7303 | ||
|
|
bc498449d3 | ||
|
|
3937f7ed2b | ||
|
|
d6de3f830f | ||
|
|
ef5ff30b13 | ||
|
|
37faf592b7 | ||
|
|
76bd1e80f7 | ||
|
|
042d076efa | ||
|
|
b9e4ab78ef | ||
|
|
90a9bb9cf1 | ||
|
|
5fb4976ec9 | ||
|
|
d6a9bc6c4b | ||
|
|
0dcdcd2960 | ||
|
|
e96a51f31c | ||
|
|
1507d869c7 | ||
|
|
b932711f08 | ||
|
|
1522794733 | ||
|
|
e00ff8ceca | ||
|
|
8e72311bc6 | ||
|
|
a8c70d784c | ||
|
|
0df7f21130 | ||
|
|
6852f586ea | ||
|
|
1414fecade | ||
|
|
c1d11236ae | ||
|
|
d09ad2cdc0 | ||
|
|
9ce5bacd22 |
@@ -93,6 +93,13 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||
|
||||
# This flag disables API rate limiting for authentication endpoints.
|
||||
# Rate limiting is enabled by default in production to prevent abuse.
|
||||
FLAG_DISABLE_API_RATE_LIMIT="false"
|
||||
|
||||
# This flag shows sponsor placements on the public landing page.
|
||||
FLAG_SHOW_SPONSORS="false"
|
||||
|
||||
# Allows dynamic OAuth client registration to use any parseable redirect URI,
|
||||
# including custom schemes, private hosts, and non-loopback http:// URLs.
|
||||
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
|
||||
|
||||
@@ -19,6 +19,13 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
|
||||
echo "::error::Merge conflict markers found in tracked files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
|
||||
@@ -249,3 +249,24 @@ jobs:
|
||||
script: |
|
||||
cd docker
|
||||
./manage_stack.sh up reactive_resume
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
env:
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}')
|
||||
|
||||
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cloudflare cache purged successfully."
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
APP_URL: http://localhost:3000
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
||||
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Test Secrets
|
||||
run: |
|
||||
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Storage
|
||||
run: mkdir -p "$LOCAL_STORAGE_PATH"
|
||||
|
||||
- name: Run Database Migrations
|
||||
run: pnpm db:migrate
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: pnpm test:e2e:ci
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -36,6 +36,8 @@ logs
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Cache
|
||||
tmp
|
||||
@@ -49,6 +51,7 @@ temp
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
|
||||
@@ -27,5 +27,8 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ Reactive Resume makes building resumes straightforward. Pick a template, fill in
|
||||
|
||||
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Reactive Resume stays free, open-source, and independent because companies choose to support the work behind it. Thank you to every sponsor who helps fund hosting, maintenance, and continued development for the community.
|
||||
|
||||
<p>
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume">
|
||||
<img src="apps/web/public/sponsors/atlas-cloud-logo-white.svg" alt="Atlas Cloud" width="320" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume) supports Reactive Resume as a project sponsor. Atlas Cloud provides a unified AI platform for developers, with access to hundreds of models for chat, image generation, video generation, media processing, and GPU cloud workloads through one API key, one endpoint, and one billing account.
|
||||
|
||||
If your company would like to sponsor Reactive Resume, email [hello@amruthpillai.com](mailto:hello@amruthpillai.com).
|
||||
|
||||
## Features
|
||||
|
||||
**Resume Building**
|
||||
|
||||
@@ -17,24 +17,24 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.80",
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
"@ai-sdk/openai": "^3.0.65",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1055.0",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@hono/node-server": "^2.0.4",
|
||||
"@ai-sdk/anthropic": "^4.0.8",
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
"@ai-sdk/openai": "^4.0.7",
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@aws-sdk/client-s3": "^3.1079.0",
|
||||
"@better-auth/api-key": "^1.6.23",
|
||||
"@better-auth/drizzle-adapter": "^1.6.23",
|
||||
"@better-auth/infra": "^0.3.4",
|
||||
"@better-auth/oauth-provider": "^1.6.23",
|
||||
"@better-auth/passkey": "^1.6.23",
|
||||
"@hono/node-server": "^2.0.8",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/experimental-ratelimit": "^1.14.3",
|
||||
"@orpc/json-schema": "^1.14.3",
|
||||
"@orpc/openapi": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/zod": "^1.14.3",
|
||||
"@orpc/client": "^1.14.6",
|
||||
"@orpc/experimental-ratelimit": "^1.14.6",
|
||||
"@orpc/json-schema": "^1.14.6",
|
||||
"@orpc/openapi": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@orpc/zod": "^1.14.6",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
@@ -46,43 +46,41 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^6.0.191",
|
||||
"ai": "^7.0.15",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"better-auth": "1.6.23",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.23",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"nodemailer": "^8.0.9",
|
||||
"ollama-ai-provider-v2": "^3.5.1",
|
||||
"pg": "^8.21.0",
|
||||
"hono": "^4.12.27",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^8.0.4",
|
||||
"nodemailer": "^9.0.3",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"pg": "^8.22.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.5.0",
|
||||
"react": "^19.2.7",
|
||||
"react-email": "^6.6.6",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.35.3",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.0",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"tsdown": "^0.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleHealth: vi.fn(),
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
@@ -18,7 +19,6 @@ const mocks = vi.hoisted(() => ({
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
handleWebAppHead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
@@ -59,13 +59,16 @@ vi.mock("../static/seo", () => ({
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
handleWebAppHead: mocks.handleWebAppHead,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
handleMcp: mocks.handleMcp,
|
||||
}));
|
||||
|
||||
vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
@@ -75,6 +78,7 @@ beforeEach(() => {
|
||||
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
@@ -85,7 +89,6 @@ beforeEach(() => {
|
||||
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||
mocks.handleWebAppHead.mockReturnValue(new Response(null));
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
@@ -101,6 +104,19 @@ describe("createApp", () => {
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes signed resume PDF downloads before the web fallback", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("pdf");
|
||||
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
@@ -119,6 +135,5 @@ describe("createApp", () => {
|
||||
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@ import { handleRpc } from "../rpc/handler";
|
||||
import { handleSchemaJson } from "../static/schema";
|
||||
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||
import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
|
||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
@@ -26,6 +27,7 @@ export function createApp() {
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/schema.json", () => handleSchemaJson());
|
||||
@@ -45,8 +47,7 @@ export function createApp() {
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { withTimeout } from "es-toolkit";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
|
||||
@@ -11,30 +12,12 @@ type CheckResult = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
|
||||
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
@@ -42,34 +25,20 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
return await storageService.healthcheck();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
return getStorageService().healthcheck();
|
||||
}
|
||||
|
||||
export async function handleHealth() {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createResumePdfDownload: vi.fn(),
|
||||
verifyResumePdfDownloadToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
createResumePdfDownload: mocks.createResumePdfDownload,
|
||||
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
|
||||
}));
|
||||
|
||||
const { handleResumePdfDownload } = await import("./resume-pdf");
|
||||
|
||||
describe("handleResumePdfDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the PDF when the signed URL token is valid", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
let response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(410);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response("Download link expired", {
|
||||
status: 410,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown) {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
return code === "NOT_FOUND" ? 404 : 500;
|
||||
}
|
||||
|
||||
export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
"Content-Type": download.body.type || "application/pdf",
|
||||
"Content-Disposition": download.headers["content-disposition"],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PDF Download]", error);
|
||||
return new Response("Failed to generate resume PDF", {
|
||||
status: errorStatus(error),
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { onError } from "@orpc/client";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||
import { appVersion } from "../app-version";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
@@ -25,7 +26,7 @@ export async function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
version: __APP_VERSION__,
|
||||
version: appVersion,
|
||||
title: "Reactive Resume",
|
||||
websiteUrl: "https://rxresu.me",
|
||||
description:
|
||||
@@ -52,6 +53,7 @@ export async function createMcpServer(request: Request) {
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
@@ -44,7 +45,7 @@ export async function handleOpenApi(request: Request) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: __APP_VERSION__,
|
||||
version: appVersion,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } fr
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
@@ -11,7 +12,7 @@ export function handleWellKnownFallback() {
|
||||
}
|
||||
|
||||
export function handleMcpServerCard() {
|
||||
return Response.json(buildMcpServerCard(__APP_VERSION__), {
|
||||
return Response.json(buildMcpServerCard(appVersion), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
@@ -12,7 +13,7 @@ export function handleSchemaJson() {
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: __APP_VERSION__,
|
||||
ETag: appVersion,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
@@ -18,41 +18,32 @@ export async function handleUpload(request: Request) {
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const headers = buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
|
||||
const buffer = toArrayBuffer(storedFile.data);
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
}
|
||||
|
||||
function inferContentTypeFromExtension(ext: string): string {
|
||||
switch (ext) {
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return new Response(toArrayBuffer(storedFile.data), { headers });
|
||||
}
|
||||
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
@@ -101,43 +92,6 @@ function makeNotModifiedResponse(etag: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): Headers {
|
||||
const headers = new Headers();
|
||||
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const { handleWebApp, handleWebAppHead } = await import("./web");
|
||||
const { handleWebApp } = await import("./web");
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
@@ -90,8 +90,10 @@ describe("web app fallback classification", () => {
|
||||
});
|
||||
|
||||
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
||||
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
|
||||
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
|
||||
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
|
||||
const unknownResponse = await handleWebApp(
|
||||
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
|
||||
);
|
||||
|
||||
expect(knownResponse.status).toBe(200);
|
||||
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
|
||||
@@ -74,23 +74,20 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// ponytail: GET and HEAD share the same routing logic; method determines body presence
|
||||
export async function handleWebApp(request: Request) {
|
||||
const isHead = request.method === "HEAD";
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response("Not Found", { status: 404 });
|
||||
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
|
||||
return new Response(isHead ? null : "Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ noindex: true });
|
||||
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
|
||||
|
||||
if (isHead) return new Response(null, { status: 200, headers });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
return new Response(html, { headers });
|
||||
}
|
||||
|
||||
export function handleWebAppHead(request: Request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ head: true, noindex: true });
|
||||
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
@@ -19,7 +19,43 @@
|
||||
<title>Reactive Resume</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
|
||||
<div id="app"></div>
|
||||
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
|
||||
<div id="initial-loader">
|
||||
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
|
||||
<div class="initial-loader__spinner"></div>
|
||||
<span class="initial-loader__sr-only">Loading</span>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes app-spin { to { transform: rotate(360deg) } }
|
||||
#initial-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
background: #09090b;
|
||||
}
|
||||
#app:not(:empty) ~ #initial-loader { display: none; }
|
||||
.initial-loader__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(250, 250, 250, 0.2);
|
||||
border-top-color: #fafafa;
|
||||
border-radius: 9999px;
|
||||
animation: app-spin 0.7s linear infinite;
|
||||
}
|
||||
.initial-loader__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && vite build",
|
||||
"build": "rimraf dist && vite build",
|
||||
"dev": "vite dev",
|
||||
"serve": "vite preview",
|
||||
"start": "vite preview",
|
||||
@@ -16,20 +16,20 @@
|
||||
"lingui:extract": "lingui extract --clean --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.193",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@ai-sdk/react": "^4.0.16",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@better-auth/api-key": "^1.6.23",
|
||||
"@better-auth/infra": "^0.3.4",
|
||||
"@better-auth/oauth-provider": "^1.6.23",
|
||||
"@better-auth/passkey": "^1.6.23",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lingui/core": "^6.1.0",
|
||||
"@lingui/react": "^6.1.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/tanstack-query": "^1.14.3",
|
||||
"@lingui/core": "^6.4.0",
|
||||
"@lingui/react": "^6.4.0",
|
||||
"@orpc/client": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@orpc/tanstack-query": "^1.14.6",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
@@ -42,64 +42,66 @@
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"@tiptap/extension-color": "^3.23.6",
|
||||
"@tiptap/extension-highlight": "^3.23.6",
|
||||
"@tiptap/extension-table": "^3.23.6",
|
||||
"@tiptap/extension-text-align": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"@tiptap/extension-color": "^3.27.1",
|
||||
"@tiptap/extension-highlight": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-text-style": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^6.0.191",
|
||||
"better-auth": "1.6.11",
|
||||
"ai": "^7.0.15",
|
||||
"better-auth": "1.6.23",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"immer": "^11.1.8",
|
||||
"js-cookie": "^3.0.7",
|
||||
"motion": "^12.40.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pg": "^8.21.0",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"immer": "^11.1.11",
|
||||
"js-cookie": "^3.0.8",
|
||||
"motion": "^12.42.2",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pg": "^8.22.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-resizable-panels": "^4.12.1",
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.1.0",
|
||||
"@lingui/cli": "^6.1.0",
|
||||
"@lingui/format-po": "^6.1.0",
|
||||
"@lingui/vite-plugin": "^6.1.0",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.4.0",
|
||||
"@lingui/cli": "^6.4.0",
|
||||
"@lingui/format-po": "^6.4.0",
|
||||
"@lingui/vite-plugin": "^6.4.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.11",
|
||||
"@tanstack/router-plugin": "^1.168.19",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14"
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Reactive Resume</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#FAFAFA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Reactive Resume</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#FAFAFA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Reactive Resume</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#09090B"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2518 137.634 61.6569 137.614C75.0665 136.968 85.1471 135.549 96.3849 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7678 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#09090B"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.5 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Reactive Resume</title>
|
||||
<rect width="512" height="512" fill="#09090B"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M183.603 184.722L156.74 220H183.545L197.032 201.54L209.701 220H237.938L211.008 183.874L211.014 183.866L210.81 183.608L205.927 177.44L197.895 167.292L197.882 167.31L197.669 167.007L196.926 166.068L196.98 166.025L185.367 149.472H157.52L183.603 184.722ZM200.951 163.096L214.129 179.747L237.027 149.472H210.872L200.951 163.096Z" fill="#FAFAFA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.1088 98.9125V190H94.8735V165.797H108.938L108.938 165.797C108.943 165.797 108.948 165.797 108.953 165.797C118.377 165.381 125.285 164.448 133.094 161.494C133.195 161.455 133.296 161.417 133.397 161.378L133.382 161.356C138.893 158.705 143.19 155.066 146.273 150.442C149.569 145.41 151.218 139.468 151.218 132.615C151.218 125.675 149.569 119.689 146.273 114.658C142.976 109.539 138.292 105.636 132.219 102.946C126.234 100.257 119.077 98.9125 110.749 98.9125H69.1088ZM136.456 165.836L111.867 170.09L125.453 190H153.039L136.456 165.836ZM94.8735 145.888H109.187C114.566 145.888 118.556 144.716 121.159 142.374C123.848 140.032 125.193 136.779 125.193 132.615C125.193 128.364 123.848 125.068 121.159 122.725C118.556 120.383 114.566 119.212 109.187 119.212H94.8735V145.888Z" fill="#FAFAFA"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg role="img" aria-labelledby="atlas-cloud-logo-title" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1224 792">
|
||||
<title id="atlas-cloud-logo-title">Atlas Cloud</title>
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
clip-path: url(#clippath);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
clip-path: url(#clippath-1);
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
clip-path: url(#clippath-2);
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #000;
|
||||
}
|
||||
</style>
|
||||
<clipPath id="clippath">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
<clipPath id="clippath-1">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
<clipPath id="clippath-2">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g class="cls-1">
|
||||
<path class="cls-3" d="M104.38,315.14L20.73,476.86c38.73-15.32,70.03-17.6,98.12-16.26l-12.85-28.4c-5.63-.57-21.86-.57-29.57,1.45l27.95-62.14s42.06,91.31,42.13,91.32c8.09,1.27,29.8,8.55,41.52,14.03l-83.66-161.72Z"/>
|
||||
</g>
|
||||
<polygon class="cls-3" points="207.87 323.37 207.87 361.57 189.68 361.57 189.68 381.76 207.87 381.76 207.87 471.35 234.07 471.35 234.07 381.76 252.87 381.76 252.87 361.57 234.07 361.57 234.07 323.37 207.87 323.37"/>
|
||||
<rect class="cls-3" x="268.07" y="323.37" width="26.19" height="147.98"/>
|
||||
<g class="cls-4">
|
||||
<path class="cls-3" d="M340.48,416.95c0,13.6,9.79,33.39,33.39,33.39,14.6,0,24.2-7.6,29.2-17.59,2.6-4.8,3.8-10,4.2-15.4.2-5.2-.8-10.6-3-15.4-4.6-10.4-14.8-19.4-30.6-19.4-21.2,0-33.19,17.2-33.19,34.19v.2ZM433.26,471.35h-26.19v-15.8c-7,12.8-21.6,19.2-36.99,19.2-35.2,0-55.8-27.4-55.8-58.4,0-34.6,25-58.19,55.8-58.19,20,0,32.19,10.6,36.99,19.4v-16h26.19v109.78Z"/>
|
||||
<path class="cls-3" d="M506.87,391.16c-.2-5.8-3.2-11.8-13.4-11.8-8.8,0-12.99,5.6-12.99,11,.2,7,8.2,10.79,18.6,13.8,17.6,4.8,36.6,10,36.8,33.59.4,22.6-18.6,36.99-41.59,36.99-17,0-40-8.8-41.99-35.39h26.19c1,11.8,11.2,14.2,16.2,14.2,8.2,0,15.2-5.4,15-12.8,0-9.4-8.2-12-28.4-18.99-14.8-4.6-26.79-12.8-26.99-28.6,0-21.6,18.19-35,39.39-35,14.8,0,36.8,6.6,39,33h-25.8Z"/>
|
||||
<path class="cls-3" d="M760.26,421.76c-9,30.59-37.8,52.39-72.39,52.39-45.19,0-77.59-35.8-77.59-76.99s30.59-76.58,77.39-76.58c42.59.2,66.79,31.59,72.59,53.19h-31.4c-4.6-10.19-17.8-26.79-41.39-26.99-28.8,0-47.39,24.2-47.39,50.39s19.4,50.79,47.6,50.79c26.39,0,38.39-20.8,41.19-26.19h31.4Z"/>
|
||||
</g>
|
||||
<rect class="cls-3" x="779.68" y="323.37" width="26.19" height="147.98"/>
|
||||
<g class="cls-5">
|
||||
<path class="cls-3" d="M852.09,416.56c0,19.2,14.59,33.39,32.59,33.39s32.4-14.2,32.4-33.6-14.4-33.59-32.4-33.59-32.59,14.2-32.59,33.59v.2ZM825.89,416.15c0-28,20.6-57.79,58.79-57.79s58.79,29.79,58.79,57.99-20.59,58.19-58.79,58.19-58.79-29.79-58.79-58.19v-.2Z"/>
|
||||
<path class="cls-3" d="M1064.87,471.35h-24.6v-14.6c-4.6,8.6-14.4,18.2-34.19,18-36-.2-42.79-27.6-42.79-46.8v-66.39h26.19v59.79c0,16.2,5.4,28.99,23.99,28.8,18.8,0,25.2-12.4,25.2-28v-60.59h26.2v109.78Z"/>
|
||||
<path class="cls-3" d="M1111.28,416.35c0,18.6,13.4,33.8,33.59,33.8,9.6,0,18.2-3.8,24.4-10,6.2-6.2,9.8-14.8,9.39-24.4,0-9.6-3.8-17.8-9.8-23.6-6-6-14.39-9.6-23.79-9.6-21.4,0-33.8,16.4-33.8,33.59v.2ZM1203.27,471.35h-25v-15.8c-4.8,8.2-15.2,19.2-37.6,19.2-33.79,0-55.59-26.4-55.59-58.4,0-35.59,25.4-58.19,55.19-58.19,21,0,32.8,11.19,36.8,17.59v-52.39h26.2v147.98Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg role="img" aria-labelledby="atlas-cloud-logo-title" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1224 792">
|
||||
<title id="atlas-cloud-logo-title">Atlas Cloud</title>
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
clip-path: url(#clippath);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
clip-path: url(#clippath-1);
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
clip-path: url(#clippath-2);
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<clipPath id="clippath">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
<clipPath id="clippath-1">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
<clipPath id="clippath-2">
|
||||
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g class="cls-1">
|
||||
<path class="cls-3" d="M104.38,315.14L20.73,476.86c38.73-15.32,70.03-17.6,98.12-16.26l-12.85-28.4c-5.63-.57-21.86-.57-29.57,1.45l27.95-62.14s42.06,91.31,42.13,91.32c8.09,1.27,29.8,8.55,41.52,14.03l-83.66-161.72Z"/>
|
||||
</g>
|
||||
<polygon class="cls-3" points="207.87 323.37 207.87 361.57 189.68 361.57 189.68 381.76 207.87 381.76 207.87 471.35 234.07 471.35 234.07 381.76 252.87 381.76 252.87 361.57 234.07 361.57 234.07 323.37 207.87 323.37"/>
|
||||
<rect class="cls-3" x="268.07" y="323.37" width="26.19" height="147.98"/>
|
||||
<g class="cls-4">
|
||||
<path class="cls-3" d="M340.48,416.95c0,13.6,9.79,33.39,33.39,33.39,14.6,0,24.2-7.6,29.2-17.59,2.6-4.8,3.8-10,4.2-15.4.2-5.2-.8-10.6-3-15.4-4.6-10.4-14.8-19.4-30.6-19.4-21.2,0-33.19,17.2-33.19,34.19v.2ZM433.26,471.35h-26.19v-15.8c-7,12.8-21.6,19.2-36.99,19.2-35.2,0-55.8-27.4-55.8-58.4,0-34.6,25-58.19,55.8-58.19,20,0,32.19,10.6,36.99,19.4v-16h26.19v109.78Z"/>
|
||||
<path class="cls-3" d="M506.87,391.16c-.2-5.8-3.2-11.8-13.4-11.8-8.8,0-12.99,5.6-12.99,11,.2,7,8.2,10.79,18.6,13.8,17.6,4.8,36.6,10,36.8,33.59.4,22.6-18.6,36.99-41.59,36.99-17,0-40-8.8-41.99-35.39h26.19c1,11.8,11.2,14.2,16.2,14.2,8.2,0,15.2-5.4,15-12.8,0-9.4-8.2-12-28.4-18.99-14.8-4.6-26.79-12.8-26.99-28.6,0-21.6,18.19-35,39.39-35,14.8,0,36.8,6.6,39,33h-25.8Z"/>
|
||||
<path class="cls-3" d="M760.26,421.76c-9,30.59-37.8,52.39-72.39,52.39-45.19,0-77.59-35.8-77.59-76.99s30.59-76.58,77.39-76.58c42.59.2,66.79,31.59,72.59,53.19h-31.4c-4.6-10.19-17.8-26.79-41.39-26.99-28.8,0-47.39,24.2-47.39,50.39s19.4,50.79,47.6,50.79c26.39,0,38.39-20.8,41.19-26.19h31.4Z"/>
|
||||
</g>
|
||||
<rect class="cls-3" x="779.68" y="323.37" width="26.19" height="147.98"/>
|
||||
<g class="cls-5">
|
||||
<path class="cls-3" d="M852.09,416.56c0,19.2,14.59,33.39,32.59,33.39s32.4-14.2,32.4-33.6-14.4-33.59-32.4-33.59-32.59,14.2-32.59,33.59v.2ZM825.89,416.15c0-28,20.6-57.79,58.79-57.79s58.79,29.79,58.79,57.99-20.59,58.19-58.79,58.19-58.79-29.79-58.79-58.19v-.2Z"/>
|
||||
<path class="cls-3" d="M1064.87,471.35h-24.6v-14.6c-4.6,8.6-14.4,18.2-34.19,18-36-.2-42.79-27.6-42.79-46.8v-66.39h26.19v59.79c0,16.2,5.4,28.99,23.99,28.8,18.8,0,25.2-12.4,25.2-28v-60.59h26.2v109.78Z"/>
|
||||
<path class="cls-3" d="M1111.28,416.35c0,18.6,13.4,33.8,33.59,33.8,9.6,0,18.2-3.8,24.4-10,6.2-6.2,9.8-14.8,9.39-24.4,0-9.6-3.8-17.8-9.8-23.6-6-6-14.39-9.6-23.79-9.6-21.4,0-33.8,16.4-33.8,33.59v.2ZM1203.27,471.35h-25v-15.8c-4.8,8.2-15.2,19.2-37.6,19.2-33.79,0-55.59-26.4-55.59-58.4,0-35.59,25.4-58.19,55.19-58.19,21,0,32.8,11.19,36.8,17.59v-52.39h26.2v147.98Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -12,28 +12,22 @@ describe("CountUp", () => {
|
||||
expect(span.getAttribute("aria-atomic")).toBe("true");
|
||||
});
|
||||
|
||||
it("seeds textContent to the 'from' value when counting up", () => {
|
||||
const { container } = render(<CountUp from={0} to={100} />);
|
||||
it("seeds textContent to 0 on initial render", () => {
|
||||
const { container } = render(<CountUp to={100} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("seeds textContent to the 'to' value when direction is down", () => {
|
||||
const { container } = render(<CountUp from={0} to={100} direction="down" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("100");
|
||||
});
|
||||
|
||||
it("formats with the separator when one is supplied", () => {
|
||||
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
|
||||
const { container } = render(<CountUp to={1234} separator="," />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("1,234");
|
||||
expect(span.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("preserves decimal places when from / to are fractional", () => {
|
||||
const { container } = render(<CountUp from={1.25} to={3.75} />);
|
||||
it("preserves decimal places when to is fractional", () => {
|
||||
const { container } = render(<CountUp to={3.75} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("1.25");
|
||||
expect(span.textContent).toBe("0.00");
|
||||
});
|
||||
|
||||
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
|
||||
|
||||
@@ -3,116 +3,72 @@ import { useCallback, useEffect, useEffectEvent, useRef } from "react";
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
|
||||
export function CountUp({
|
||||
to,
|
||||
from = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = "",
|
||||
startWhen = true,
|
||||
separator = "",
|
||||
onStart,
|
||||
onEnd,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
}: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? to : from);
|
||||
const motionValue = useMotionValue(0);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness,
|
||||
});
|
||||
const springValue = useSpring(motionValue, { damping, stiffness });
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
minimumFractionDigits: maxDecimals,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
|
||||
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
|
||||
const notifyStart = useEffectEvent(() => onStart?.());
|
||||
const notifyEnd = useEffectEvent(() => onEnd?.());
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatCurrentValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction]);
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
notifyStart();
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
notifyEnd();
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, duration]);
|
||||
if (isInView) motionValue.set(to);
|
||||
}, [isInView, motionValue, to]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatCurrentValue(latest);
|
||||
}
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue]);
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
|
||||
title={icon}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => onChange(icon)}
|
||||
onClick={() => {
|
||||
if (icon) onChange(icon);
|
||||
}}
|
||||
className="flex size-full items-center justify-center hover:bg-accent"
|
||||
>
|
||||
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
|
||||
@@ -100,7 +102,7 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
|
||||
<Popover {...popoverProps}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<Button size="icon" variant="outline" aria-label={t`Pick an icon`} {...props}>
|
||||
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveHighlightToolbarState } from "./rich-input";
|
||||
|
||||
describe("resolveHighlightToolbarState", () => {
|
||||
it("shows legacy colorless highlights as default yellow and clearable", () => {
|
||||
expect(resolveHighlightToolbarState(true, null)).toEqual({
|
||||
visibleHighlightColor: "rgba(255, 255, 0, 1)",
|
||||
canClearHighlight: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show or clear a highlight when no highlight mark is active", () => {
|
||||
expect(resolveHighlightToolbarState(false, null)).toEqual({
|
||||
visibleHighlightColor: undefined,
|
||||
canClearHighlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowsInSimpleIcon,
|
||||
ArrowsOutSimpleIcon,
|
||||
CodeBlockIcon,
|
||||
CodeSimpleIcon,
|
||||
ColumnsPlusLeftIcon,
|
||||
ColumnsPlusRightIcon,
|
||||
HighlighterCircleIcon,
|
||||
KeyReturnIcon,
|
||||
LinkBreakIcon,
|
||||
@@ -17,10 +14,6 @@ import {
|
||||
ListNumbersIcon,
|
||||
MinusIcon,
|
||||
ParagraphIcon,
|
||||
PlusIcon,
|
||||
RowsPlusBottomIcon,
|
||||
RowsPlusTopIcon,
|
||||
TableIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignJustifyIcon,
|
||||
TextAlignLeftIcon,
|
||||
@@ -37,11 +30,9 @@ import {
|
||||
TextOutdentIcon,
|
||||
TextStrikethroughIcon,
|
||||
TextUnderlineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
|
||||
@@ -56,27 +47,26 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Toggle } from "@reactive-resume/ui/components/toggle";
|
||||
import { isDarkColor } from "@reactive-resume/utils/color";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
codeBlock: {
|
||||
enableTabIndentation: true,
|
||||
},
|
||||
codeBlock: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
@@ -86,9 +76,16 @@ const extensions = [
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure(),
|
||||
Highlight.configure({ multicolor: true }).extend({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const color = HTMLAttributes["data-color"] as string | undefined;
|
||||
if (color && isDarkColor(color)) {
|
||||
HTMLAttributes.style = `${HTMLAttributes.style ?? ""}; color: #ffffff`;
|
||||
}
|
||||
return ["mark", HTMLAttributes, 0];
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
|
||||
TableKit.configure(),
|
||||
];
|
||||
|
||||
type Props = UseEditorOptions & {
|
||||
@@ -223,10 +220,12 @@ function useEditorToolbarState(editor: Editor) {
|
||||
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
|
||||
toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
|
||||
|
||||
// Highlight
|
||||
// Highlight Color
|
||||
isHighlight: ctx.editor.isActive("highlight") ?? false,
|
||||
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
|
||||
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
|
||||
highlightColor: (ctx.editor.getAttributes("highlight").color as string | undefined) ?? null,
|
||||
canHighlightColor: ctx.editor.can().chain().toggleHighlight().run() ?? false,
|
||||
setHighlightColor: (color: string) => ctx.editor.chain().focus().toggleHighlight({ color }).run(),
|
||||
unsetHighlightColor: () => ctx.editor.chain().focus().unsetHighlight().run(),
|
||||
|
||||
// Text Color
|
||||
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
|
||||
@@ -335,28 +334,8 @@ function useEditorToolbarState(editor: Editor) {
|
||||
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
|
||||
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
|
||||
|
||||
// Code Block
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
|
||||
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
|
||||
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
|
||||
|
||||
// Table
|
||||
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
|
||||
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
|
||||
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
|
||||
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
|
||||
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
|
||||
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
|
||||
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
|
||||
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
|
||||
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
|
||||
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
|
||||
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
|
||||
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
|
||||
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
|
||||
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
|
||||
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
|
||||
// Character Count
|
||||
characterCount: ctx.editor.getText().length,
|
||||
|
||||
// Hard Break
|
||||
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
|
||||
@@ -372,6 +351,12 @@ function useEditorToolbarState(editor: Editor) {
|
||||
|
||||
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
|
||||
type EditorToolbarProps = {
|
||||
editor: Editor;
|
||||
isFullscreen: boolean;
|
||||
@@ -384,6 +369,11 @@ function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
|
||||
}
|
||||
|
||||
function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
const { visibleHighlightColor, canClearHighlight } = resolveHighlightToolbarState(
|
||||
state.isHighlight,
|
||||
state.highlightColor,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-y-0.5 rounded-md rounded-b-none border border-b-0">
|
||||
<Toggle
|
||||
@@ -434,17 +424,65 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<TextStrikethroughIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Highlight`}
|
||||
pressed={state.isHighlight}
|
||||
disabled={!state.canHighlight}
|
||||
onPressedChange={state.toggleHighlight}
|
||||
<ColorPicker
|
||||
defaultValue={defaultHighlightColor}
|
||||
value={visibleHighlightColor}
|
||||
onChange={state.setHighlightColor}
|
||||
trigger={
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className={cn("rounded-none px-2", state.isHighlight && "bg-muted text-foreground")}
|
||||
title={t`Highlight`}
|
||||
disabled={!state.canHighlightColor}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<HighlighterCircleIcon className="size-3.5" />
|
||||
<span
|
||||
className="mt-0.5 h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: visibleHighlightColor ?? "currentColor" }}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HighlighterCircleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
<PopoverHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm shadow-xs"
|
||||
style={{ backgroundColor: visibleHighlightColor ?? defaultHighlightColor }}
|
||||
>
|
||||
<HighlighterCircleIcon className="size-4" />
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverTitle>
|
||||
<Trans>Highlight Color</Trans>
|
||||
</PopoverTitle>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans comment="Preset or custom shade refer to the color picker">
|
||||
Choose a preset or custom shade.
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={state.unsetHighlightColor}
|
||||
disabled={!canClearHighlight}
|
||||
>
|
||||
<Trans comment="Clear the highlight color">Clear</Trans>
|
||||
</Button>
|
||||
</PopoverHeader>
|
||||
</ColorPicker>
|
||||
|
||||
<ColorPicker
|
||||
defaultValue={defaultTextColor}
|
||||
@@ -511,7 +549,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
aria-label={t`Paragraph and heading style`}
|
||||
className="rounded-none"
|
||||
>
|
||||
{match(state)
|
||||
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
|
||||
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
|
||||
@@ -584,7 +628,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
aria-label={t`Text alignment`}
|
||||
className="rounded-none"
|
||||
>
|
||||
{match(state)
|
||||
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
|
||||
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
|
||||
@@ -660,6 +710,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Decrease indent`}
|
||||
disabled={!state.canLiftListItem}
|
||||
onClick={state.liftListItem}
|
||||
>
|
||||
@@ -671,6 +722,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Increase indent`}
|
||||
disabled={!state.canSinkListItem}
|
||||
onClick={state.sinkListItem}
|
||||
>
|
||||
@@ -685,6 +737,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Remove link`}
|
||||
onClick={state.unsetLink}
|
||||
>
|
||||
<LinkBreakIcon className="size-3.5" />
|
||||
@@ -695,6 +748,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Add link`}
|
||||
onClick={state.setLink}
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
@@ -713,73 +767,6 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<CodeSimpleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Code Block`}
|
||||
pressed={state.isCodeBlock}
|
||||
disabled={!state.canCodeBlock}
|
||||
onPressedChange={state.toggleCodeBlock}
|
||||
>
|
||||
<CodeBlockIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Table`}
|
||||
>
|
||||
<TableIcon className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
|
||||
<PlusIcon />
|
||||
<Trans>Insert Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
|
||||
<ColumnsPlusLeftIcon />
|
||||
<Trans>Add Column Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
|
||||
<ColumnsPlusRightIcon />
|
||||
<Trans>Add Column After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
|
||||
<RowsPlusTopIcon />
|
||||
<Trans>Add Row Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
|
||||
<RowsPlusBottomIcon />
|
||||
<Trans>Add Row After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Column</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Row</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
@@ -801,6 +788,10 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<span className="ml-auto px-2 text-muted-foreground text-xs tabular-nums" aria-live="polite">
|
||||
<Trans comment="Character count readout for the rich-text editor">{state.characterCount} characters</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Website } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TagIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -50,7 +50,7 @@ export function URLInput<TValue extends Website>({ value, onChange, hideLabelBut
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
|
||||
const urlValue = stripPrefix(value.url);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
|
||||
@@ -5,7 +5,18 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { ErrorScreen } from "./error-screen";
|
||||
|
||||
// `Link` from tanstack/react-router requires a Router context. Stub it out with
|
||||
// a plain anchor so we can render the screen in isolation.
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const { ErrorScreen } = await import("./error-screen");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
@@ -25,21 +36,27 @@ const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
|
||||
};
|
||||
|
||||
describe("ErrorScreen", () => {
|
||||
it("shows the error message provided", () => {
|
||||
renderError({ error: new Error("Network is down") });
|
||||
expect(screen.getByText("Network is down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the user-facing error heading", () => {
|
||||
it("shows a distinct, non-blaming error heading", () => {
|
||||
renderError();
|
||||
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when the Refresh button is clicked", () => {
|
||||
it("does not surface the raw error message to the user", () => {
|
||||
renderError({ error: new Error("Network is down") });
|
||||
expect(screen.queryByText("Network is down")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when the Try again button is clicked", () => {
|
||||
const reset = vi.fn();
|
||||
renderError({ reset });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers an absolute dashboard escape", () => {
|
||||
renderError();
|
||||
const link = screen.getByRole("link", { name: /go to dashboard/i });
|
||||
expect(link.getAttribute("href")).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { ArrowClockwiseIcon, HouseIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
|
||||
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
export function ErrorScreen({ reset }: ErrorComponentProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
@@ -13,15 +14,24 @@ export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
<Trans>Something went wrong</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
<AlertDescription>
|
||||
<Trans>An unexpected error stopped this page from loading. You can try again or head back.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
|
||||
<Link to="/dashboard" className={buttonVariants({ variant: "secondary" })}>
|
||||
<HouseIcon />
|
||||
<Trans>Go to dashboard</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LoadingScreen } from "./loading-screen";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("LoadingScreen", () => {
|
||||
it("renders a spinner and the loading text", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LoadingScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
it("renders the Reactive Resume icon and spinner", () => {
|
||||
render(<LoadingScreen />);
|
||||
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
const icons = screen.getAllByAltText("Reactive Resume");
|
||||
expect(icons).toHaveLength(2);
|
||||
expect(icons.map((icon) => icon.getAttribute("src"))).toEqual(["/icon/dark.svg", "/icon/light.svg"]);
|
||||
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the same icon asset as the initial HTML loader", () => {
|
||||
const html = readFileSync("index.html", "utf8");
|
||||
|
||||
expect(html).toContain('src="/icon/dark.svg"');
|
||||
expect(html).not.toContain('src="/logo/dark.svg"');
|
||||
});
|
||||
|
||||
it("fills the viewport (fixed inset-0)", () => {
|
||||
const { container } = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LoadingScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const { container } = render(<LoadingScreen />);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("fixed");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-background">
|
||||
<BrandIcon variant="icon" className="size-12" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,27 +21,33 @@ beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const renderScreen = (routeId = "/missing") =>
|
||||
const renderScreen = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<NotFoundScreen isNotFound routeId={routeId as never} />
|
||||
<NotFoundScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("NotFoundScreen", () => {
|
||||
it("renders the documented error heading", () => {
|
||||
it("renders a distinct not-found heading", () => {
|
||||
renderScreen();
|
||||
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
|
||||
expect(screen.getByText("We couldn't find that page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the routeId that triggered the not-found", () => {
|
||||
renderScreen("/dashboard/missing-page");
|
||||
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
|
||||
it("does not surface the raw routeId", () => {
|
||||
renderScreen();
|
||||
expect(screen.queryByText(/\/dashboard\/missing-page/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Go Back link", () => {
|
||||
it("offers an absolute dashboard escape", () => {
|
||||
renderScreen();
|
||||
const link = screen.getByRole("link", { name: /go back/i });
|
||||
expect(link.getAttribute("href")).toBe("..");
|
||||
const link = screen.getByRole("link", { name: /go to dashboard/i });
|
||||
expect(link.getAttribute("href")).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("offers an absolute home escape", () => {
|
||||
renderScreen();
|
||||
const link = screen.getByRole("link", { name: /go home/i });
|
||||
expect(link.getAttribute("href")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { NotFoundRouteProps } from "@tanstack/react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { HouseIcon, MagnifyingGlassIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
|
||||
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
export function NotFoundScreen() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
@@ -14,15 +13,24 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
<Trans>We couldn't find that page</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
<AlertDescription>
|
||||
<Trans>The page you're looking for may have been moved or no longer exists.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Link to=".." className={buttonVariants()}>
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Link to="/dashboard" className={buttonVariants()}>
|
||||
<MagnifyingGlassIcon />
|
||||
<Trans>Go to dashboard</Trans>
|
||||
</Link>
|
||||
|
||||
<Link to="/" className={buttonVariants({ variant: "secondary" })}>
|
||||
<HouseIcon />
|
||||
<Trans>Go home</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,21 @@ describe("LevelDisplay", () => {
|
||||
expect(wrapper.getAttribute("aria-label")).toContain("4");
|
||||
});
|
||||
|
||||
it("applies custom decoration and icon sizes in pixels", () => {
|
||||
const { container } = render(
|
||||
<LevelDisplay type="circle" icon="star" level={2} decorationSizePx={18} iconSizePx={20} />,
|
||||
);
|
||||
|
||||
const shapes = container.querySelectorAll("div[data-active]");
|
||||
expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" });
|
||||
|
||||
const { container: iconContainer } = render(
|
||||
<LevelDisplay type="icon" icon="star" level={2} decorationSizePx={16} />,
|
||||
);
|
||||
const icon = iconContainer.querySelector("i");
|
||||
expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" });
|
||||
});
|
||||
|
||||
it("merges extra className into the wrapper", () => {
|
||||
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
|
||||
@@ -3,14 +3,35 @@ import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type Props = z.infer<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
|
||||
type Props = z.infer<typeof levelDesignSchema> &
|
||||
React.ComponentProps<"div"> & {
|
||||
level: number;
|
||||
decorationSizePx?: number | undefined;
|
||||
iconSizePx?: number | undefined;
|
||||
};
|
||||
|
||||
const LEVEL_ITEM_KEYS = ["level-1", "level-2", "level-3", "level-4", "level-5"] as const;
|
||||
|
||||
export function LevelDisplay({ icon, type, level, className, ...props }: Props) {
|
||||
const defaultDecorationClassName = "size-2.5";
|
||||
|
||||
export function LevelDisplay({ icon, type, level, className, decorationSizePx, iconSizePx, ...props }: Props) {
|
||||
if (level === 0) return null;
|
||||
if (type === "hidden" || icon === "") return null;
|
||||
|
||||
const decorationStyle =
|
||||
decorationSizePx === undefined
|
||||
? undefined
|
||||
: ({ width: decorationSizePx, height: decorationSizePx } satisfies React.CSSProperties);
|
||||
const resolvedIconSizePx = iconSizePx ?? decorationSizePx;
|
||||
const iconStyle =
|
||||
resolvedIconSizePx === undefined
|
||||
? undefined
|
||||
: ({
|
||||
fontSize: resolvedIconSizePx,
|
||||
width: resolvedIconSizePx,
|
||||
height: resolvedIconSizePx,
|
||||
} satisfies React.CSSProperties);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
@@ -29,8 +50,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
<div
|
||||
key={itemKey}
|
||||
data-active={isActive}
|
||||
style={decorationStyle}
|
||||
className={cn(
|
||||
"h-2.5 flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
|
||||
"flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
|
||||
decorationSizePx === undefined && "h-2.5",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
)}
|
||||
/>
|
||||
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
return (
|
||||
<i
|
||||
key={itemKey}
|
||||
className={cn("ph size-2.5 text-(--page-primary-color)", `ph-${icon}`, !isActive && "opacity-40")}
|
||||
style={iconStyle}
|
||||
className={cn(
|
||||
"ph text-(--page-primary-color)",
|
||||
resolvedIconSizePx === undefined && defaultDecorationClassName,
|
||||
`ph-${icon}`,
|
||||
!isActive && "opacity-40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
<div
|
||||
key={itemKey}
|
||||
data-active={isActive}
|
||||
style={decorationStyle}
|
||||
className={cn(
|
||||
"size-2.5 border border-(--page-primary-color)",
|
||||
"border border-(--page-primary-color)",
|
||||
decorationSizePx === undefined && defaultDecorationClassName,
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
type === "circle" && "rounded-full",
|
||||
type === "rectangle" && "w-7",
|
||||
|
||||