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 | ||
|
|
1d761be05b | ||
|
|
c875541001 | ||
|
|
16f4d2c072 | ||
|
|
b491582637 | ||
|
|
c6a654191c | ||
|
|
8461aa65d5 | ||
|
|
b04eef1479 | ||
|
|
7bff6644d8 | ||
|
|
8da780c868 | ||
|
|
dd1e37e579 | ||
|
|
19b412d84d | ||
|
|
7eea6675c0 | ||
|
|
273e17c0d3 | ||
|
|
17cddbad65 | ||
|
|
7557ab13ab | ||
|
|
c66560ee12 | ||
|
|
24c882fa9f | ||
|
|
86fff7237f | ||
|
|
266bc291eb | ||
|
|
6ec4da7914 | ||
|
|
75e9446134 | ||
|
|
39e88dd365 | ||
|
|
3596102c63 |
@@ -12,7 +12,7 @@ APP_URL="http://localhost:3000"
|
||||
# --- Database (PostgreSQL) ---
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
@@ -54,8 +54,8 @@ OAUTH_SCOPES="openid profile email"
|
||||
# --- Email (optional) ---
|
||||
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
||||
# email to the console instead.
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
@@ -72,13 +72,13 @@ SMTP_SECURE="false"
|
||||
S3_ACCESS_KEY_ID="seaweedfs"
|
||||
S3_SECRET_ACCESS_KEY="seaweedfs"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ENDPOINT="http://localhost:8333"
|
||||
S3_ENDPOINT="http://seaweedfs:8333"
|
||||
S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- AI Agent Workspace (optional) ---
|
||||
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
REDIS_URL="redis://redis:6379"
|
||||
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||
|
||||
# --- Feature Flags ---
|
||||
@@ -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
|
||||
@@ -44,12 +46,16 @@ temp
|
||||
|
||||
# AI
|
||||
.codex
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,21 @@ Internal packages are source-consumed through `package.json` export maps that po
|
||||
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
|
||||
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
|
||||
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
|
||||
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
|
||||
|
||||
```ts
|
||||
type IntentSelectFieldProps<TValue extends string> = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
};
|
||||
|
||||
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Package and feature boundaries
|
||||
|
||||
|
||||
@@ -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**
|
||||
@@ -143,7 +157,7 @@ The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/amruthpillai/reactive-resume.git
|
||||
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||
cd reactive-resume
|
||||
|
||||
# Start all services
|
||||
|
||||
@@ -13,25 +13,28 @@
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"imports": {
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.78",
|
||||
"@ai-sdk/google": "^3.0.75",
|
||||
"@ai-sdk/openai": "^3.0.64",
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@aws-sdk/client-s3": "^3.1049.0",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.8",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@hono/node-server": "^2.0.3",
|
||||
"@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:*",
|
||||
@@ -42,44 +45,42 @@
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"ai": "^6.0.185",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"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.5",
|
||||
"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.46.1",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.19",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"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.1.5",
|
||||
"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.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||
"tsdown": "^0.22.0",
|
||||
"tsx": "^4.22.2",
|
||||
"@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.6"
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ const oauthAuthorizeSanitizedParams = [
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
@@ -199,7 +201,7 @@ export async function handleOAuth(request: Request) {
|
||||
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!["exp", "sig"].includes(key)) {
|
||||
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.187",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.8",
|
||||
"@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.0.1",
|
||||
"@lingui/react": "^6.0.1",
|
||||
"@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:*",
|
||||
@@ -39,65 +39,69 @@
|
||||
"@reactive-resume/fonts": "workspace:*",
|
||||
"@reactive-resume/import": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-router": "^1.170.4",
|
||||
"@tiptap/extension-color": "^3.23.4",
|
||||
"@tiptap/extension-highlight": "^3.23.4",
|
||||
"@tiptap/extension-table": "^3.23.4",
|
||||
"@tiptap/extension-text-align": "^3.23.4",
|
||||
"@tiptap/extension-text-style": "^3.23.4",
|
||||
"@tiptap/pm": "^3.23.4",
|
||||
"@tiptap/react": "^3.23.4",
|
||||
"@tiptap/starter-kit": "^3.23.4",
|
||||
"@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.1",
|
||||
"@uiw/react-color-colorful": "^2.10.1",
|
||||
"ai": "^6.0.185",
|
||||
"better-auth": "1.6.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^7.0.15",
|
||||
"better-auth": "1.6.23",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"immer": "^11.1.8",
|
||||
"js-cookie": "^3.0.7",
|
||||
"motion": "^12.39.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.1",
|
||||
"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": {
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.0.1",
|
||||
"@lingui/cli": "^6.0.1",
|
||||
"@lingui/format-po": "^6.0.1",
|
||||
"@lingui/vite-plugin": "^6.0.1",
|
||||
"@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.6",
|
||||
"@tanstack/router-plugin": "^1.168.19",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260518.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.13"
|
||||
"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 |
@@ -1,5 +1,5 @@
|
||||
import type React from "react";
|
||||
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import { m, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import { useRef } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
@@ -64,7 +64,7 @@ export const CometCard = ({
|
||||
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<motion.div
|
||||
<m.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -75,12 +75,12 @@ export const CometCard = ({
|
||||
>
|
||||
{children}
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,120 +1,76 @@
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction, formatValue]);
|
||||
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
if (typeof onStart === "function") {
|
||||
onStart();
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(0);
|
||||
}, []);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
if (typeof onEnd === "function") {
|
||||
onEnd();
|
||||
}
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
useEffect(() => {
|
||||
if (isInView) motionValue.set(to);
|
||||
}, [isInView, motionValue, to]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(latest);
|
||||
}
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue, formatValue]);
|
||||
}, [springValue]);
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
|
||||
type SpotlightProps = {
|
||||
duration?: number;
|
||||
@@ -24,13 +24,13 @@ export const Spotlight = ({
|
||||
xOffset = 100,
|
||||
}: SpotlightProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
@@ -64,9 +64,9 @@ export const Spotlight = ({
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
transition={{ duration, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", repeatType: "reverse" }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
@@ -100,7 +100,7 @@ export const Spotlight = ({
|
||||
transform: "rotate(45deg) translate(180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { m } from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const textClassName = cn("fill-transparent font-bold text-3xl leading-none tracking-tight");
|
||||
@@ -13,20 +13,9 @@ type TextMaskEffectProps = {
|
||||
|
||||
export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": ariaHidden }: TextMaskEffectProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [cursor, setCursor] = useState({ x: 0, y: 0 });
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
|
||||
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
|
||||
|
||||
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
@@ -40,7 +29,12 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={(e) => {
|
||||
setCursor({ x: e.clientX, y: e.clientY });
|
||||
if (!svgRef.current) return;
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const cxPercentage = ((e.clientX - svgRect.left) / svgRect.width) * 100;
|
||||
const cyPercentage = ((e.clientY - svgRect.top) / svgRect.height) * 100;
|
||||
|
||||
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
@@ -56,7 +50,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
)}
|
||||
</linearGradient>
|
||||
|
||||
<motion.radialGradient
|
||||
<m.radialGradient
|
||||
r="20%"
|
||||
id="revealMask"
|
||||
animate={maskPosition}
|
||||
@@ -66,7 +60,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
>
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="black" />
|
||||
</motion.radialGradient>
|
||||
</m.radialGradient>
|
||||
|
||||
<mask id="textMask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
|
||||
@@ -85,7 +79,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
{text}
|
||||
</text>
|
||||
|
||||
<motion.text
|
||||
<m.text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
@@ -97,7 +91,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</motion.text>
|
||||
</m.text>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
@@ -24,6 +24,7 @@ import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
|
||||
const RETURN_KEY = "Enter";
|
||||
const COMMA_KEY = ",";
|
||||
const EMPTY_CHIPS: string[] = [];
|
||||
|
||||
type ChipItemProps = {
|
||||
id: string;
|
||||
@@ -34,7 +35,11 @@ type ChipItemProps = {
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function ChipDragPreview({ chip }: { chip: string }) {
|
||||
type ChipDragPreviewProps = {
|
||||
chip: string;
|
||||
};
|
||||
|
||||
function ChipDragPreview({ chip }: ChipDragPreviewProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -45,7 +50,11 @@ function ChipDragPreview({ chip }: { chip: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChipDragOverlay({ activeChip }: { activeChip: string | null }) {
|
||||
type ChipDragOverlayProps = {
|
||||
activeChip: string | null;
|
||||
};
|
||||
|
||||
function ChipDragOverlay({ activeChip }: ChipDragOverlayProps) {
|
||||
const overlay = (
|
||||
<DragOverlay dropAnimation={null}>{activeChip ? <ChipDragPreview chip={activeChip} /> : null}</DragOverlay>
|
||||
);
|
||||
@@ -65,7 +74,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.92, y: -4 }}
|
||||
animate={{ opacity: isDragging ? 0.62 : 1, scale: 1, y: 0 }}
|
||||
@@ -86,7 +95,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
)}
|
||||
>
|
||||
<span className="max-w-32 truncate sm:max-w-44">{chip}</span>
|
||||
<motion.div
|
||||
<m.div
|
||||
initial={false}
|
||||
animate={isEditing ? { opacity: 1 } : { opacity: 0.66 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
@@ -124,9 +133,9 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +146,14 @@ type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
|
||||
hideDescription?: boolean;
|
||||
};
|
||||
|
||||
export function ChipInput({ value, defaultValue = [], onChange, className, hideDescription = false, ...props }: Props) {
|
||||
export function ChipInput({
|
||||
value,
|
||||
defaultValue = EMPTY_CHIPS,
|
||||
onChange,
|
||||
className,
|
||||
hideDescription = false,
|
||||
...props
|
||||
}: Props) {
|
||||
const [chips, setChips] = useControlledState<string[]>({
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -154,7 +170,10 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
|
||||
const addChips = React.useCallback(
|
||||
(values: string[]) => {
|
||||
const nextValues = values.map((chip) => chip.trim()).filter(Boolean);
|
||||
const nextValues = values.flatMap((chip) => {
|
||||
const trimmed = chip.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
if (nextValues.length === 0) return;
|
||||
|
||||
const newChips = Array.from(new Set([...chips, ...nextValues]));
|
||||
@@ -195,7 +214,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (editingIndex !== null && editingIndex > index) {
|
||||
setEditingIndex(editingIndex - 1);
|
||||
setEditingIndex((current) => (current !== null && current > index ? current - 1 : current));
|
||||
}
|
||||
},
|
||||
[chips, setChips, editingIndex],
|
||||
@@ -328,7 +347,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
className={cn("max-h-24 overflow-y-auto px-2 py-1.5", hasChips ? "border-border/70 border-b" : "hidden")}
|
||||
>
|
||||
<SortableContext items={chips} strategy={rectSortingStrategy}>
|
||||
<motion.div layout className="flex flex-wrap gap-1">
|
||||
<m.div layout className="flex flex-wrap gap-1">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
@@ -342,7 +361,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-1.5 px-2", hasChips ? "py-1.5" : "py-0")}>
|
||||
@@ -355,11 +374,11 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
className="h-9 flex-1 border-none px-0 py-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
|
||||
className="h-9 flex-1 border-none p-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{chips.length > 0 && (
|
||||
<motion.span
|
||||
<m.span
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{
|
||||
@@ -376,7 +395,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
)}
|
||||
>
|
||||
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
|
||||
</motion.span>
|
||||
</m.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,10 @@ import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const queryResult = vi.hoisted(() => ({ data: undefined as number | undefined }));
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
}));
|
||||
@@ -14,7 +18,7 @@ vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { statistics: { github: { getStarCount: { queryOptions: () => ({}) } } } },
|
||||
}));
|
||||
vi.mock("../animation/count-up", () => ({
|
||||
CountUp: ({ to }: { to: number }) => <span data-testid="count-up">{to.toLocaleString()}</span>,
|
||||
CountUp: ({ to }: CountUpProps) => <span data-testid="count-up">{to.toLocaleString()}</span>,
|
||||
}));
|
||||
|
||||
const { GithubStarsButton } = await import("./github-stars-button");
|
||||
@@ -35,12 +39,12 @@ const renderButton = () =>
|
||||
);
|
||||
|
||||
describe("GithubStarsButton", () => {
|
||||
it("renders an anchor pointing at the project repo with rel=noopener and target=_blank", () => {
|
||||
it("renders an anchor pointing at the project repo with rel=noopener noreferrer and target=_blank", () => {
|
||||
renderButton();
|
||||
const link = screen.getByRole("button") as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
|
||||
expect(link.target).toBe("_blank");
|
||||
expect(link.rel).toBe("noopener");
|
||||
expect(link.rel).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it("uses the no-count aria-label when star count hasn't loaded yet", () => {
|
||||
|
||||
@@ -18,7 +18,12 @@ export function GithubStarsButton() {
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/amruthpillai/reactive-resume"
|
||||
aria-label={ariaLabel}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
|
||||
@@ -5,23 +5,20 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
type GridProps = {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
cellComponent: React.ComponentType<
|
||||
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
|
||||
>;
|
||||
cellProps: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// react-window's <Grid> uses ResizeObserver / IntersectionObserver heavily and
|
||||
// expects measurable layouts that happy-dom can't provide. Stub it with a simple
|
||||
// pass-through that renders the first row of cells via the supplied cellComponent.
|
||||
vi.mock("react-window", () => ({
|
||||
Grid: ({
|
||||
rowCount,
|
||||
columnCount,
|
||||
cellComponent: CellComponent,
|
||||
cellProps,
|
||||
}: {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
cellComponent: React.ComponentType<
|
||||
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
|
||||
>;
|
||||
cellProps: Record<string, unknown>;
|
||||
}) => {
|
||||
Grid: ({ rowCount, columnCount, cellComponent: CellComponent, cellProps }: GridProps) => {
|
||||
const cells: React.ReactNode[] = [];
|
||||
for (let r = 0; r < Math.min(rowCount, 1); r++) {
|
||||
for (let c = 0; c < columnCount; c++) {
|
||||
|
||||
@@ -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 & {
|
||||
@@ -196,7 +193,7 @@ export function RichInput({ value, onChange, style, className, editorClassName,
|
||||
);
|
||||
}
|
||||
|
||||
function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen: boolean }) {
|
||||
function useEditorToolbarState(editor: Editor) {
|
||||
const prompt = usePrompt();
|
||||
|
||||
const state = useEditorState({
|
||||
@@ -223,10 +220,12 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
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 EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
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(),
|
||||
@@ -367,6 +346,34 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
},
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
|
||||
const state = useEditorToolbarState(editor);
|
||||
|
||||
return renderEditorToolbar(state, isFullscreen);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -417,17 +424,65 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<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}
|
||||
@@ -494,7 +549,13 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<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" />)
|
||||
@@ -567,7 +628,13 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<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" />)
|
||||
@@ -643,6 +710,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Decrease indent`}
|
||||
disabled={!state.canLiftListItem}
|
||||
onClick={state.liftListItem}
|
||||
>
|
||||
@@ -654,6 +722,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Increase indent`}
|
||||
disabled={!state.canSinkListItem}
|
||||
onClick={state.sinkListItem}
|
||||
>
|
||||
@@ -668,6 +737,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Remove link`}
|
||||
onClick={state.unsetLink}
|
||||
>
|
||||
<LinkBreakIcon className="size-3.5" />
|
||||
@@ -678,6 +748,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Add link`}
|
||||
onClick={state.setLink}
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
@@ -696,73 +767,6 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<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}
|
||||
@@ -784,6 +788,10 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||