mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
APP_URL: http://localhost:3000
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
||||
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Test Secrets
|
||||
run: |
|
||||
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Storage
|
||||
run: mkdir -p "$LOCAL_STORAGE_PATH"
|
||||
|
||||
- name: Run Database Migrations
|
||||
run: pnpm db:migrate
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: pnpm test:e2e:ci
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -36,6 +36,8 @@ logs
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Cache
|
||||
tmp
|
||||
@@ -49,6 +51,7 @@ temp
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
|
||||
Vendored
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-28
@@ -17,17 +17,17 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.85",
|
||||
"@ai-sdk/google": "^3.0.83",
|
||||
"@ai-sdk/openai": "^3.0.72",
|
||||
"@ai-sdk/openai-compatible": "^2.0.51",
|
||||
"@aws-sdk/client-s3": "^3.1070.0",
|
||||
"@better-auth/api-key": "^1.6.19",
|
||||
"@better-auth/drizzle-adapter": "^1.6.19",
|
||||
"@better-auth/infra": "^0.2.14",
|
||||
"@better-auth/oauth-provider": "^1.6.19",
|
||||
"@better-auth/passkey": "^1.6.19",
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"@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.6",
|
||||
"@orpc/experimental-ratelimit": "^1.14.6",
|
||||
@@ -46,42 +46,40 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^6.0.207",
|
||||
"ai": "^7.0.15",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.19",
|
||||
"better-auth": "1.6.23",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.10",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.47.1",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.25",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"nodemailer": "^9.0.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.21.0",
|
||||
"pg": "^8.22.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.7",
|
||||
"react-email": "^6.6.3",
|
||||
"react-email": "^6.6.6",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.1",
|
||||
"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.3",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260617.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const mocks = vi.hoisted(() => ({
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
handleWebAppHead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
@@ -60,7 +59,6 @@ vi.mock("../static/seo", () => ({
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
handleWebAppHead: mocks.handleWebAppHead,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
@@ -91,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", () => {
|
||||
@@ -138,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,7 +12,7 @@ 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";
|
||||
@@ -47,8 +47,7 @@ export function createApp() {
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { withTimeout } from "es-toolkit";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
|
||||
@@ -11,30 +12,12 @@ type CheckResult = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
|
||||
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
@@ -42,34 +25,20 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
return await storageService.healthcheck();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
return getStorageService().healthcheck();
|
||||
}
|
||||
|
||||
export async function handleHealth() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+488
-196
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+485
-193
File diff suppressed because it is too large
Load Diff
+483
-192
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+488
-196
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+491
-199
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+488
-196
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+488
-196
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+489
-197
File diff suppressed because it is too large
Load Diff
+475
-184
File diff suppressed because it is too large
Load Diff
+33
-32
@@ -16,12 +16,12 @@
|
||||
"lingui:extract": "lingui extract --clean --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.209",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@better-auth/api-key": "^1.6.19",
|
||||
"@better-auth/infra": "^0.2.14",
|
||||
"@better-auth/oauth-provider": "^1.6.19",
|
||||
"@better-auth/passkey": "^1.6.19",
|
||||
"@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",
|
||||
@@ -42,40 +42,41 @@
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tiptap/extension-color": "^3.27.0",
|
||||
"@tiptap/extension-highlight": "^3.27.0",
|
||||
"@tiptap/extension-table": "^3.27.0",
|
||||
"@tiptap/extension-text-align": "^3.27.0",
|
||||
"@tiptap/extension-text-style": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"@tiptap/extension-color": "^3.27.1",
|
||||
"@tiptap/extension-highlight": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-text-style": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^6.0.207",
|
||||
"better-auth": "1.6.19",
|
||||
"ai": "^7.0.15",
|
||||
"better-auth": "1.6.23",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"es-toolkit": "^1.47.1",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"immer": "^11.1.8",
|
||||
"immer": "^11.1.11",
|
||||
"js-cookie": "^3.0.8",
|
||||
"motion": "^12.40.0",
|
||||
"pdfjs-dist": "6.0.227",
|
||||
"pg": "^8.21.0",
|
||||
"motion": "^12.42.2",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pg": "^8.22.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-resizable-panels": "^4.12.1",
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
@@ -83,24 +84,24 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^8.0.0",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.4.0",
|
||||
"@lingui/cli": "^6.4.0",
|
||||
"@lingui/format-po": "^6.4.0",
|
||||
"@lingui/vite-plugin": "^6.4.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.18",
|
||||
"@tanstack/router-plugin": "^1.168.19",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260617.2",
|
||||
"@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",
|
||||
"typescript": "^6.0.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"vite": "^8.0.16"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,28 +12,22 @@ describe("CountUp", () => {
|
||||
expect(span.getAttribute("aria-atomic")).toBe("true");
|
||||
});
|
||||
|
||||
it("seeds textContent to the 'from' value when counting up", () => {
|
||||
const { container } = render(<CountUp from={0} to={100} />);
|
||||
it("seeds textContent to 0 on initial render", () => {
|
||||
const { container } = render(<CountUp to={100} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("seeds textContent to the 'to' value when direction is down", () => {
|
||||
const { container } = render(<CountUp from={0} to={100} direction="down" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("100");
|
||||
});
|
||||
|
||||
it("formats with the separator when one is supplied", () => {
|
||||
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
|
||||
const { container } = render(<CountUp to={1234} separator="," />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("1,234");
|
||||
expect(span.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("preserves decimal places when from / to are fractional", () => {
|
||||
const { container } = render(<CountUp from={1.25} to={3.75} />);
|
||||
it("preserves decimal places when to is fractional", () => {
|
||||
const { container } = render(<CountUp to={3.75} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("1.25");
|
||||
expect(span.textContent).toBe("0.00");
|
||||
});
|
||||
|
||||
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
|
||||
|
||||
@@ -3,116 +3,72 @@ import { useCallback, useEffect, useEffectEvent, useRef } from "react";
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
|
||||
export function CountUp({
|
||||
to,
|
||||
from = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = "",
|
||||
startWhen = true,
|
||||
separator = "",
|
||||
onStart,
|
||||
onEnd,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
}: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? to : from);
|
||||
const motionValue = useMotionValue(0);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness,
|
||||
});
|
||||
const springValue = useSpring(motionValue, { damping, stiffness });
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
minimumFractionDigits: maxDecimals,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
|
||||
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
|
||||
const notifyStart = useEffectEvent(() => onStart?.());
|
||||
const notifyEnd = useEffectEvent(() => onEnd?.());
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatCurrentValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction]);
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
notifyStart();
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
notifyEnd();
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, duration]);
|
||||
if (isInView) motionValue.set(to);
|
||||
}, [isInView, motionValue, to]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatCurrentValue(latest);
|
||||
}
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue]);
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
|
||||
title={icon}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => onChange(icon)}
|
||||
onClick={() => {
|
||||
if (icon) onChange(icon);
|
||||
}}
|
||||
className="flex size-full items-center justify-center hover:bg-accent"
|
||||
>
|
||||
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
|
||||
@@ -100,7 +102,7 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
|
||||
<Popover {...popoverProps}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<Button size="icon" variant="outline" aria-label={t`Pick an icon`} {...props}>
|
||||
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -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,7 +47,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
@@ -76,9 +66,7 @@ const extensions = [
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
codeBlock: {
|
||||
enableTabIndentation: true,
|
||||
},
|
||||
codeBlock: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
@@ -98,7 +86,6 @@ const extensions = [
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
|
||||
TableKit.configure(),
|
||||
];
|
||||
|
||||
type Props = UseEditorOptions & {
|
||||
@@ -347,28 +334,8 @@ function useEditorToolbarState(editor: Editor) {
|
||||
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
|
||||
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
|
||||
|
||||
// Code Block
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
|
||||
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
|
||||
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
|
||||
|
||||
// Table
|
||||
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
|
||||
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
|
||||
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
|
||||
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
|
||||
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
|
||||
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
|
||||
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
|
||||
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
|
||||
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
|
||||
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
|
||||
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
|
||||
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
|
||||
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
|
||||
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
|
||||
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
|
||||
// Character Count
|
||||
characterCount: ctx.editor.getText().length,
|
||||
|
||||
// Hard Break
|
||||
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
|
||||
@@ -582,7 +549,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
aria-label={t`Paragraph and heading style`}
|
||||
className="rounded-none"
|
||||
>
|
||||
{match(state)
|
||||
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
|
||||
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
|
||||
@@ -655,7 +628,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
aria-label={t`Text alignment`}
|
||||
className="rounded-none"
|
||||
>
|
||||
{match(state)
|
||||
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
|
||||
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
|
||||
@@ -731,6 +710,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Decrease indent`}
|
||||
disabled={!state.canLiftListItem}
|
||||
onClick={state.liftListItem}
|
||||
>
|
||||
@@ -742,6 +722,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Increase indent`}
|
||||
disabled={!state.canSinkListItem}
|
||||
onClick={state.sinkListItem}
|
||||
>
|
||||
@@ -756,6 +737,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Remove link`}
|
||||
onClick={state.unsetLink}
|
||||
>
|
||||
<LinkBreakIcon className="size-3.5" />
|
||||
@@ -766,6 +748,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Add link`}
|
||||
onClick={state.setLink}
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
@@ -784,73 +767,6 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
<CodeSimpleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Code Block`}
|
||||
pressed={state.isCodeBlock}
|
||||
disabled={!state.canCodeBlock}
|
||||
onPressedChange={state.toggleCodeBlock}
|
||||
>
|
||||
<CodeBlockIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Table`}
|
||||
>
|
||||
<TableIcon className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
|
||||
<PlusIcon />
|
||||
<Trans>Insert Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
|
||||
<ColumnsPlusLeftIcon />
|
||||
<Trans>Add Column Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
|
||||
<ColumnsPlusRightIcon />
|
||||
<Trans>Add Column After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
|
||||
<RowsPlusTopIcon />
|
||||
<Trans>Add Row Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
|
||||
<RowsPlusBottomIcon />
|
||||
<Trans>Add Row After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Column</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Row</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
@@ -872,6 +788,10 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
|
||||
>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<span className="ml-auto px-2 text-muted-foreground text-xs tabular-nums" aria-live="polite">
|
||||
<Trans comment="Character count readout for the rich-text editor">{state.characterCount} characters</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Website } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TagIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -50,7 +50,7 @@ export function URLInput<TValue extends Website>({ value, onChange, hideLabelBut
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
|
||||
const urlValue = stripPrefix(value.url);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
|
||||
@@ -5,7 +5,18 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { ErrorScreen } from "./error-screen";
|
||||
|
||||
// `Link` from tanstack/react-router requires a Router context. Stub it out with
|
||||
// a plain anchor so we can render the screen in isolation.
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const { ErrorScreen } = await import("./error-screen");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
@@ -25,21 +36,27 @@ const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
|
||||
};
|
||||
|
||||
describe("ErrorScreen", () => {
|
||||
it("shows the error message provided", () => {
|
||||
renderError({ error: new Error("Network is down") });
|
||||
expect(screen.getByText("Network is down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the user-facing error heading", () => {
|
||||
it("shows a distinct, non-blaming error heading", () => {
|
||||
renderError();
|
||||
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when the Refresh button is clicked", () => {
|
||||
it("does not surface the raw error message to the user", () => {
|
||||
renderError({ error: new Error("Network is down") });
|
||||
expect(screen.queryByText("Network is down")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when the Try again button is clicked", () => {
|
||||
const reset = vi.fn();
|
||||
renderError({ reset });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers an absolute dashboard escape", () => {
|
||||
renderError();
|
||||
const link = screen.getByRole("link", { name: /go to dashboard/i });
|
||||
expect(link.getAttribute("href")).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { ArrowClockwiseIcon, HouseIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
|
||||
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
export function ErrorScreen({ reset }: ErrorComponentProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
@@ -13,15 +14,24 @@ export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
<Trans>Something went wrong</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
<AlertDescription>
|
||||
<Trans>An unexpected error stopped this page from loading. You can try again or head back.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
|
||||
<Link to="/dashboard" className={buttonVariants({ variant: "secondary" })}>
|
||||
<HouseIcon />
|
||||
<Trans>Go to dashboard</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LoadingScreen } from "./loading-screen";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("LoadingScreen", () => {
|
||||
it("renders a spinner and the loading text", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LoadingScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
it("renders the Reactive Resume icon and spinner", () => {
|
||||
render(<LoadingScreen />);
|
||||
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
const icons = screen.getAllByAltText("Reactive Resume");
|
||||
expect(icons).toHaveLength(2);
|
||||
expect(icons.map((icon) => icon.getAttribute("src"))).toEqual(["/icon/dark.svg", "/icon/light.svg"]);
|
||||
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the same icon asset as the initial HTML loader", () => {
|
||||
const html = readFileSync("index.html", "utf8");
|
||||
|
||||
expect(html).toContain('src="/icon/dark.svg"');
|
||||
expect(html).not.toContain('src="/logo/dark.svg"');
|
||||
});
|
||||
|
||||
it("fills the viewport (fixed inset-0)", () => {
|
||||
const { container } = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LoadingScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const { container } = render(<LoadingScreen />);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("fixed");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-background">
|
||||
<BrandIcon variant="icon" className="size-12" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,27 +21,33 @@ beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const renderScreen = (routeId = "/missing") =>
|
||||
const renderScreen = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<NotFoundScreen isNotFound routeId={routeId as never} />
|
||||
<NotFoundScreen />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("NotFoundScreen", () => {
|
||||
it("renders the documented error heading", () => {
|
||||
it("renders a distinct not-found heading", () => {
|
||||
renderScreen();
|
||||
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
|
||||
expect(screen.getByText("We couldn't find that page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the routeId that triggered the not-found", () => {
|
||||
renderScreen("/dashboard/missing-page");
|
||||
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
|
||||
it("does not surface the raw routeId", () => {
|
||||
renderScreen();
|
||||
expect(screen.queryByText(/\/dashboard\/missing-page/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Go Back link", () => {
|
||||
it("offers an absolute dashboard escape", () => {
|
||||
renderScreen();
|
||||
const link = screen.getByRole("link", { name: /go back/i });
|
||||
expect(link.getAttribute("href")).toBe("..");
|
||||
const link = screen.getByRole("link", { name: /go to dashboard/i });
|
||||
expect(link.getAttribute("href")).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("offers an absolute home escape", () => {
|
||||
renderScreen();
|
||||
const link = screen.getByRole("link", { name: /go home/i });
|
||||
expect(link.getAttribute("href")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { NotFoundRouteProps } from "@tanstack/react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { HouseIcon, MagnifyingGlassIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
|
||||
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
export function NotFoundScreen() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
@@ -14,15 +13,24 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
<Trans>We couldn't find that page</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
<AlertDescription>
|
||||
<Trans>The page you're looking for may have been moved or no longer exists.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Link to=".." className={buttonVariants()}>
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Link to="/dashboard" className={buttonVariants()}>
|
||||
<MagnifyingGlassIcon />
|
||||
<Trans>Go to dashboard</Trans>
|
||||
</Link>
|
||||
|
||||
<Link to="/" className={buttonVariants({ variant: "secondary" })}>
|
||||
<HouseIcon />
|
||||
<Trans>Go home</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
type ComboboxOption<TValue extends string | number = string> = {
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
// Plain-text label used for the collapsed trigger and filtering when `label` is a ReactNode.
|
||||
// Without it, a JSX label falls back to String(value) (e.g. a raw enum or locale code) in the trigger.
|
||||
textValue?: string;
|
||||
group?: string | ComboboxOptionGroup;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
@@ -177,7 +180,8 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
});
|
||||
|
||||
const itemToStringLabel = React.useCallback(
|
||||
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
|
||||
(item: ComboboxOption<TValue>) =>
|
||||
item.textValue ?? (typeof item.label === "string" ? item.label : String(item.value)),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -188,7 +192,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
|
||||
const filter = React.useCallback(
|
||||
(item: ComboboxOption<TValue>, query: string) => {
|
||||
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
|
||||
const labelStr = typeof item.label === "string" ? item.label : (item.textValue ?? String(item.value));
|
||||
if (contains(labelStr, query)) return true;
|
||||
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
|
||||
},
|
||||
|
||||
@@ -36,7 +36,8 @@ describe("Copyright", () => {
|
||||
|
||||
it("includes the app version string", () => {
|
||||
renderCopyright();
|
||||
expect(screen.getByText(/v9\.9\.9/)).toBeInTheDocument();
|
||||
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
|
||||
expect(screen.getByText("9.9.9")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className into the wrapper", () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function Copyright({ className, ...props }: Props) {
|
||||
|
||||
<p className="mt-4">
|
||||
<Trans comment="App version label in footer; includes semantic version variable">
|
||||
Reactive Resume v{__APP_VERSION__}
|
||||
Reactive Resume v<bdi>{__APP_VERSION__}</bdi>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,9 @@ type ToastOptions = {
|
||||
unstyled: boolean;
|
||||
};
|
||||
|
||||
const useCookieMock = vi.hoisted(() => ({
|
||||
setDismissed: vi.fn(),
|
||||
const cookieMock = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
@@ -26,8 +26,11 @@ const toastMock = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/ui/hooks/use-cookie", () => ({
|
||||
useCookie: vi.fn(() => [useCookieMock.value, useCookieMock.setDismissed, vi.fn()] as const),
|
||||
vi.mock("js-cookie", () => ({
|
||||
default: {
|
||||
get: vi.fn(() => cookieMock.value ?? undefined),
|
||||
set: cookieMock.set,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -51,8 +54,8 @@ describe("DonationToast", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
useCookieMock.value = null;
|
||||
useCookieMock.setDismissed.mockClear();
|
||||
cookieMock.value = null;
|
||||
cookieMock.set.mockClear();
|
||||
toastMock.toast.custom.mockClear();
|
||||
toastMock.toast.dismiss.mockClear();
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
@@ -89,7 +92,7 @@ describe("DonationToast", () => {
|
||||
});
|
||||
|
||||
it("does not show the toast after it has been dismissed", () => {
|
||||
useCookieMock.value = "true";
|
||||
cookieMock.value = "true";
|
||||
|
||||
render(<DonationToast />);
|
||||
|
||||
@@ -110,7 +113,10 @@ describe("DonationToast", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
expires: new Date("2026-06-10T12:05:00.000Z"),
|
||||
});
|
||||
expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast");
|
||||
@@ -126,7 +132,10 @@ describe("DonationToast", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Donate" }));
|
||||
|
||||
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
|
||||
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
expires: new Date("2026-06-10T12:05:00.000Z"),
|
||||
});
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { useCallback } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTimeout } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useCookie } from "@reactive-resume/ui/hooks/use-cookie";
|
||||
|
||||
const TOAST_ID = "donation-toast";
|
||||
const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -14,7 +14,14 @@ const DISMISSED_COOKIE_EXPIRES_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const getDismissedCookieExpiresAt = () => new Date(Date.now() + DISMISSED_COOKIE_EXPIRES_MS);
|
||||
|
||||
export function DonationToast() {
|
||||
const [dismissed, setDismissed] = useCookie(DISMISSED_COOKIE_NAME);
|
||||
// ponytail: inlined from @reactive-resume/ui/hooks/use-cookie — only consumer, one read + one set-with-expiry
|
||||
const [dismissed, setDismissedState] = useState<string | null>(() => Cookies.get(DISMISSED_COOKIE_NAME) ?? null);
|
||||
|
||||
const setDismissed = useCallback((value: string, options?: { expires?: Date }) => {
|
||||
// Attributes match the former useCookie DEFAULT_COOKIE_ATTRIBUTES; options (expiry) override.
|
||||
Cookies.set(DISMISSED_COOKIE_NAME, value, { path: "/", secure: true, sameSite: "lax", ...options });
|
||||
setDismissedState(value);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(() => {
|
||||
if (dismissed === "true") return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import type { AnyDialogRendererEntry } from "../renderer-registry";
|
||||
import { CreateApiKeyDialog } from "./create";
|
||||
|
||||
export const apiKeyDialogRendererRegistry = defineDialogRendererRegistry("api-key", [
|
||||
defineDialogRenderer("api-key.create", () => <CreateApiKeyDialog />),
|
||||
]);
|
||||
export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [
|
||||
{ type: "api-key.create", render: () => <CreateApiKeyDialog /> },
|
||||
];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import type { AnyDialogRendererEntry } from "../renderer-registry";
|
||||
import { ChangePasswordDialog } from "./change-password";
|
||||
import { DisableTwoFactorDialog } from "./disable-two-factor";
|
||||
import { EnableTwoFactorDialog } from "./enable-two-factor";
|
||||
|
||||
export const authDialogRendererRegistry = defineDialogRendererRegistry("auth", [
|
||||
defineDialogRenderer("auth.change-password", () => <ChangePasswordDialog />),
|
||||
defineDialogRenderer("auth.two-factor.enable", () => <EnableTwoFactorDialog />),
|
||||
defineDialogRenderer("auth.two-factor.disable", () => <DisableTwoFactorDialog />),
|
||||
]);
|
||||
export const authDialogRenderers: readonly AnyDialogRendererEntry[] = [
|
||||
{ type: "auth.change-password", render: () => <ChangePasswordDialog /> },
|
||||
{ type: "auth.two-factor.enable", render: () => <EnableTwoFactorDialog /> },
|
||||
{ type: "auth.two-factor.disable", render: () => <DisableTwoFactorDialog /> },
|
||||
];
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DialogSchema, DialogType } from "./schemas";
|
||||
|
||||
export interface DialogRendererEntry<T extends DialogType = DialogType> {
|
||||
// ponytail: defineDialogRenderer/defineDialogRendererRegistry were identity fns; inlined
|
||||
type DialogRendererEntry<T extends DialogType = DialogType> = {
|
||||
type: T;
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export type AnyDialogRendererEntry = {
|
||||
[T in DialogType]: DialogRendererEntry<T>;
|
||||
}[DialogType];
|
||||
|
||||
export interface DialogRendererRegistry {
|
||||
domain: string;
|
||||
renderers: readonly AnyDialogRendererEntry[];
|
||||
}
|
||||
|
||||
export const defineDialogRenderer = <T extends DialogType>(
|
||||
type: T,
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode,
|
||||
): DialogRendererEntry<T> => ({ type, render });
|
||||
|
||||
export const defineDialogRendererRegistry = (
|
||||
domain: string,
|
||||
renderers: readonly AnyDialogRendererEntry[],
|
||||
): DialogRendererRegistry => ({ domain, renderers });
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import type { DialogSchema } from "./schemas";
|
||||
import { apiKeyDialogRendererRegistry } from "./api-key/registry";
|
||||
import { authDialogRendererRegistry } from "./auth/registry";
|
||||
import { resumeDialogRendererRegistry } from "./resume/registry";
|
||||
|
||||
const dialogRendererRegistries = [
|
||||
authDialogRendererRegistry,
|
||||
apiKeyDialogRendererRegistry,
|
||||
resumeDialogRendererRegistry,
|
||||
] as const;
|
||||
import { apiKeyDialogRenderers } from "./api-key/registry";
|
||||
import { authDialogRenderers } from "./auth/registry";
|
||||
import { resumeDialogRenderers } from "./resume/registry";
|
||||
|
||||
const dialogRendererByType = new Map(
|
||||
dialogRendererRegistries.flatMap((registry) =>
|
||||
registry.renderers.map((renderer) => [renderer.type, renderer] as const),
|
||||
[...authDialogRenderers, ...apiKeyDialogRenderers, ...resumeDialogRenderers].map(
|
||||
(renderer) => [renderer.type, renderer] as const,
|
||||
),
|
||||
);
|
||||
|
||||
export const renderDialog = (dialog: DialogSchema | null) => {
|
||||
if (!dialog) return null;
|
||||
|
||||
const renderer = dialogRendererByType.get(dialog.type);
|
||||
if (renderer) return renderer.render(dialog as never);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectJsonImportType } from "./import";
|
||||
|
||||
describe("detectJsonImportType", () => {
|
||||
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
|
||||
expect(detectJsonImportType({ basics: { name: "A" }, work: [] })).toBe("json-resume-json");
|
||||
});
|
||||
|
||||
it("detects the current Reactive Resume schema by metadata.page", () => {
|
||||
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { page: { locale: "en-US" } } })).toBe(
|
||||
"reactive-resume-json",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects the legacy v4 schema by metadata without a page key", () => {
|
||||
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { template: "azurill" } })).toBe(
|
||||
"reactive-resume-v4-json",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty string for unrecognized shapes", () => {
|
||||
expect(detectJsonImportType({ foo: "bar" })).toBe("");
|
||||
expect(detectJsonImportType(null)).toBe("");
|
||||
expect(detectJsonImportType("nope")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,14 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
|
||||
import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
|
||||
import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
|
||||
import { parseJSONResume } from "@reactive-resume/import/json-resume";
|
||||
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
|
||||
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { client, orpc } from "@/libs/orpc/client";
|
||||
@@ -88,6 +88,56 @@ function fileToBase64(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// #7: sniff the source format from magic bytes + JSON shape rather than trusting the extension/MIME
|
||||
// (multiple resume interchange formats share the .json extension). Returns "" when unrecognized.
|
||||
async function detectImportType(file: File): Promise<ImportType> {
|
||||
const name = file.name.toLowerCase();
|
||||
const mime = file.type;
|
||||
|
||||
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
|
||||
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46; // "%PDF"
|
||||
const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04"
|
||||
|
||||
if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf";
|
||||
if (
|
||||
isZip ||
|
||||
mime === "application/msword" ||
|
||||
mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
name.endsWith(".docx") ||
|
||||
name.endsWith(".doc")
|
||||
) {
|
||||
return "docx";
|
||||
}
|
||||
|
||||
if (mime === "application/json" || name.endsWith(".json")) {
|
||||
try {
|
||||
return detectJsonImportType(JSON.parse(await file.text()));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function detectJsonImportType(parsed: unknown): ImportType {
|
||||
if (!parsed || typeof parsed !== "object") return "";
|
||||
const data = parsed as Record<string, unknown>;
|
||||
|
||||
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
|
||||
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
|
||||
|
||||
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
|
||||
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
|
||||
if ("sections" in data || "metadata" in data) {
|
||||
const metadata = data.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
|
||||
return "reactive-resume-json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
@@ -96,8 +146,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
const [isImporting, setIsImporting] = useState<boolean>(false);
|
||||
|
||||
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
|
||||
const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
|
||||
const { hasUsableProvider, isLoading: isLoadingAiProviders } = useHasUsableAiProvider();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
@@ -118,27 +167,21 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
let data: ResumeData | undefined;
|
||||
|
||||
if (value.type === "json-resume-json") {
|
||||
const json = await value.file.text();
|
||||
const importer = new JSONResumeImporter();
|
||||
data = importer.parse(json);
|
||||
data = parseJSONResume(await value.file.text());
|
||||
}
|
||||
|
||||
if (value.type === "reactive-resume-json") {
|
||||
const json = await value.file.text();
|
||||
const importer = new ReactiveResumeJSONImporter();
|
||||
data = importer.parse(json);
|
||||
data = parseReactiveResumeJSON(await value.file.text());
|
||||
}
|
||||
|
||||
if (value.type === "reactive-resume-v4-json") {
|
||||
const json = await value.file.text();
|
||||
const importer = new ReactiveResumeV4JSONImporter();
|
||||
data = importer.parse(json);
|
||||
data = parseReactiveResumeV4JSON(await value.file.text());
|
||||
}
|
||||
|
||||
if (value.type === "pdf") {
|
||||
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
|
||||
if (!hasAIProvider)
|
||||
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
|
||||
if (!hasUsableProvider)
|
||||
throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`);
|
||||
|
||||
const base64 = await fileToBase64(value.file);
|
||||
|
||||
@@ -149,8 +192,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
|
||||
if (value.type === "docx") {
|
||||
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
|
||||
if (!hasAIProvider)
|
||||
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
|
||||
if (!hasUsableProvider)
|
||||
throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`);
|
||||
|
||||
const base64 = await fileToBase64(value.file);
|
||||
|
||||
@@ -205,19 +248,24 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
});
|
||||
|
||||
const type = useStore(form.store, (s) => s.values.type);
|
||||
const file = useStore(form.store, (s) => s.values.file);
|
||||
const aiRequired = type === "pdf" || type === "docx";
|
||||
|
||||
const onSelectFile = () => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.click();
|
||||
};
|
||||
|
||||
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
form.setFieldValue("file", file);
|
||||
const onUploadFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
form.setFieldValue("file", selected);
|
||||
// #7: pre-select the source format from the file's content; the user can still override below.
|
||||
form.setFieldValue("type", await detectImportType(selected));
|
||||
};
|
||||
|
||||
useFormBlocker(form);
|
||||
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
|
||||
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
@@ -242,83 +290,12 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="type">
|
||||
<form.Field name="file">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
<Trans>File</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
showClear={false}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
const nextType = value as ImportType;
|
||||
if (nextType !== field.state.value) form.setFieldValue("file", undefined);
|
||||
field.handleChange(nextType);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "reactive-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for current Reactive Resume JSON format",
|
||||
message: "Reactive Resume (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "reactive-resume-v4-json",
|
||||
label: t({
|
||||
comment: "Import source option for legacy Reactive Resume v4 JSON format",
|
||||
message: "Reactive Resume v4 (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "json-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for standard JSON Resume format",
|
||||
message: "JSON Resume",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "PDF",
|
||||
})}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "docx",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "Microsoft Word",
|
||||
})}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field key={type} name="file">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className={cn(!type && "hidden")}
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl>
|
||||
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
|
||||
|
||||
@@ -345,8 +322,98 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{file && (
|
||||
<form.Field name="type">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
showClear={false}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => field.handleChange(value as ImportType)}
|
||||
options={[
|
||||
{
|
||||
value: "reactive-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for current Reactive Resume JSON format",
|
||||
message: "Reactive Resume (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "reactive-resume-v4-json",
|
||||
label: t({
|
||||
comment: "Import source option for legacy Reactive Resume v4 JSON format",
|
||||
message: "Reactive Resume v4 (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "json-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for standard JSON Resume format",
|
||||
message: "JSON Resume",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
textValue: t({ comment: "File format label in import source selector", message: "PDF" }),
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{t({ comment: "File format label in import source selector", message: "PDF" })}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "docx",
|
||||
textValue: t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "Microsoft Word",
|
||||
}),
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "Microsoft Word",
|
||||
})}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!field.state.value && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>We couldn't detect the format automatically — please choose it above.</Trans>
|
||||
</p>
|
||||
)}
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
)}
|
||||
|
||||
{aiRequired && !isLoadingAiProviders && !hasUsableProvider && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Importing from PDF or Word requires a connected AI provider.</Trans>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
nativeButton={false}
|
||||
render={<Link to="/dashboard/settings/integrations">{t`Set up a provider`}</Link>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!type || isImporting}>
|
||||
<Button type="submit" disabled={!type || !file || isImporting || (aiRequired && !hasUsableProvider)}>
|
||||
{isImporting ? <Spinner /> : null}
|
||||
{isImporting ? t`Importing…` : t`Import`}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeI
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -59,7 +59,10 @@ const defaultValues: FormValues = {
|
||||
};
|
||||
|
||||
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
// Skip the unsaved-changes guard when we close as a result of a successful create.
|
||||
const didCreateRef = useRef(false);
|
||||
|
||||
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
|
||||
|
||||
@@ -75,9 +78,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
const toastId = toast.loading(t`Creating your resume...`);
|
||||
|
||||
createResume(value, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (id) => {
|
||||
didCreateRef.current = true;
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
@@ -92,7 +97,9 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
form.setFieldValue("slug", slugify(name));
|
||||
}, [form, name]);
|
||||
|
||||
useFormBlocker(form);
|
||||
useFormBlocker(form, {
|
||||
shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting,
|
||||
});
|
||||
|
||||
const onCreateSampleResume = () => {
|
||||
const values = form.state.values;
|
||||
@@ -108,9 +115,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
const toastId = toast.loading(t`Creating your resume...`);
|
||||
|
||||
createResume(data, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (id) => {
|
||||
didCreateRef.current = true;
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import type { AnyDialogRendererEntry } from "../renderer-registry";
|
||||
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
|
||||
import { ImportResumeDialog } from "./import";
|
||||
import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award";
|
||||
@@ -18,44 +18,46 @@ import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/sum
|
||||
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer";
|
||||
import { TemplateGalleryDialog } from "./template/gallery";
|
||||
|
||||
export const resumeDialogRendererRegistry = defineDialogRendererRegistry("resume", [
|
||||
defineDialogRenderer("resume.create", () => <CreateResumeDialog />),
|
||||
defineDialogRenderer("resume.update", ({ data }) => <UpdateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.duplicate", ({ data }) => <DuplicateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.import", () => <ImportResumeDialog />),
|
||||
defineDialogRenderer("resume.template.gallery", () => <TemplateGalleryDialog />),
|
||||
defineDialogRenderer("resume.sections.profiles.create", ({ data }) => <CreateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.profiles.update", ({ data }) => <UpdateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.create", ({ data }) => <CreateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.update", ({ data }) => <UpdateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.create", ({ data }) => <CreateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.update", ({ data }) => <UpdateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.create", ({ data }) => <CreateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.update", ({ data }) => <UpdateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.create", ({ data }) => <CreateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.update", ({ data }) => <UpdateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.certifications.create", ({ data }) => (
|
||||
<CreateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.certifications.update", ({ data }) => (
|
||||
<UpdateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.languages.create", ({ data }) => <CreateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.languages.update", ({ data }) => <UpdateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.create", ({ data }) => <CreatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.update", ({ data }) => <UpdatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.create", ({ data }) => <CreateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.update", ({ data }) => <UpdateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.create", ({ data }) => <CreateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.update", ({ data }) => <UpdateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.create", ({ data }) => <CreateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.update", ({ data }) => <UpdateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.create", ({ data }) => <CreateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.update", ({ data }) => <UpdateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.create", ({ data }) => <CreateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.update", ({ data }) => <UpdateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.create", ({ data }) => <CreateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.update", ({ data }) => <UpdateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.create", ({ data }) => <CreateCustomSectionDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.update", ({ data }) => <UpdateCustomSectionDialog data={data} />),
|
||||
]);
|
||||
export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [
|
||||
{ type: "resume.create", render: () => <CreateResumeDialog /> },
|
||||
{ type: "resume.update", render: ({ data }) => <UpdateResumeDialog data={data} /> },
|
||||
{ type: "resume.duplicate", render: ({ data }) => <DuplicateResumeDialog data={data} /> },
|
||||
{ type: "resume.import", render: () => <ImportResumeDialog /> },
|
||||
{ type: "resume.template.gallery", render: () => <TemplateGalleryDialog /> },
|
||||
{ type: "resume.sections.profiles.create", render: ({ data }) => <CreateProfileDialog data={data} /> },
|
||||
{ type: "resume.sections.profiles.update", render: ({ data }) => <UpdateProfileDialog data={data} /> },
|
||||
{ type: "resume.sections.experience.create", render: ({ data }) => <CreateExperienceDialog data={data} /> },
|
||||
{ type: "resume.sections.experience.update", render: ({ data }) => <UpdateExperienceDialog data={data} /> },
|
||||
{ type: "resume.sections.education.create", render: ({ data }) => <CreateEducationDialog data={data} /> },
|
||||
{ type: "resume.sections.education.update", render: ({ data }) => <UpdateEducationDialog data={data} /> },
|
||||
{ type: "resume.sections.skills.create", render: ({ data }) => <CreateSkillDialog data={data} /> },
|
||||
{ type: "resume.sections.skills.update", render: ({ data }) => <UpdateSkillDialog data={data} /> },
|
||||
{ type: "resume.sections.projects.create", render: ({ data }) => <CreateProjectDialog data={data} /> },
|
||||
{ type: "resume.sections.projects.update", render: ({ data }) => <UpdateProjectDialog data={data} /> },
|
||||
{
|
||||
type: "resume.sections.certifications.create",
|
||||
render: ({ data }) => <CreateCertificationDialog data={data} />,
|
||||
},
|
||||
{
|
||||
type: "resume.sections.certifications.update",
|
||||
render: ({ data }) => <UpdateCertificationDialog data={data} />,
|
||||
},
|
||||
{ type: "resume.sections.languages.create", render: ({ data }) => <CreateLanguageDialog data={data} /> },
|
||||
{ type: "resume.sections.languages.update", render: ({ data }) => <UpdateLanguageDialog data={data} /> },
|
||||
{ type: "resume.sections.publications.create", render: ({ data }) => <CreatePublicationDialog data={data} /> },
|
||||
{ type: "resume.sections.publications.update", render: ({ data }) => <UpdatePublicationDialog data={data} /> },
|
||||
{ type: "resume.sections.awards.create", render: ({ data }) => <CreateAwardDialog data={data} /> },
|
||||
{ type: "resume.sections.awards.update", render: ({ data }) => <UpdateAwardDialog data={data} /> },
|
||||
{ type: "resume.sections.interests.create", render: ({ data }) => <CreateInterestDialog data={data} /> },
|
||||
{ type: "resume.sections.interests.update", render: ({ data }) => <UpdateInterestDialog data={data} /> },
|
||||
{ type: "resume.sections.volunteer.create", render: ({ data }) => <CreateVolunteerDialog data={data} /> },
|
||||
{ type: "resume.sections.volunteer.update", render: ({ data }) => <UpdateVolunteerDialog data={data} /> },
|
||||
{ type: "resume.sections.references.create", render: ({ data }) => <CreateReferenceDialog data={data} /> },
|
||||
{ type: "resume.sections.references.update", render: ({ data }) => <UpdateReferenceDialog data={data} /> },
|
||||
{ type: "resume.sections.summary.create", render: ({ data }) => <CreateSummaryItemDialog data={data} /> },
|
||||
{ type: "resume.sections.summary.update", render: ({ data }) => <UpdateSummaryItemDialog data={data} /> },
|
||||
{ type: "resume.sections.cover-letter.create", render: ({ data }) => <CreateCoverLetterDialog data={data} /> },
|
||||
{ type: "resume.sections.cover-letter.update", render: ({ data }) => <UpdateCoverLetterDialog data={data} /> },
|
||||
{ type: "resume.sections.custom.create", render: ({ data }) => <CreateCustomSectionDialog data={data} /> },
|
||||
{ type: "resume.sections.custom.update", render: ({ data }) => <UpdateCustomSectionDialog data={data} /> },
|
||||
];
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { awardItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
@@ -23,6 +15,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = awardItemSchema;
|
||||
|
||||
@@ -57,36 +50,16 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<AwardForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new award</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<AwardForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,36 +82,16 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<AwardForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing award</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<AwardForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
@@ -22,6 +14,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = certificationItemSchema;
|
||||
|
||||
@@ -56,36 +49,16 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<CertificationForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new certification</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<CertificationForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,36 +81,16 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<CertificationForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing certification</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<CertificationForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
@@ -19,6 +11,7 @@ import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = coverLetterItemSchema;
|
||||
|
||||
@@ -53,36 +46,17 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<CoverLetterForm form={form} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new cover letter</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
singleColumn
|
||||
>
|
||||
<CoverLetterForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,36 +84,17 @@ export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<CoverLetterForm form={form} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing cover letter</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
singleColumn
|
||||
>
|
||||
<CoverLetterForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ const defaultValues: FormValues = {
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
@@ -81,6 +83,8 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
|
||||
icon: data?.icon ?? "",
|
||||
columns: data?.columns ?? 1,
|
||||
hidden: data?.hidden ?? false,
|
||||
keepTogether: data?.keepTogether ?? false,
|
||||
startOnNewPage: data?.startOnNewPage ?? false,
|
||||
items: data?.items ?? [],
|
||||
},
|
||||
validators: { onSubmit: formSchema },
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
@@ -22,6 +14,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = educationItemSchema;
|
||||
|
||||
@@ -59,36 +52,16 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<EducationForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new education</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<EducationForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,36 +84,16 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<EducationForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing education</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<EducationForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,6 @@ import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
|
||||
import { experienceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
@@ -26,6 +19,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = experienceItemSchema;
|
||||
|
||||
@@ -62,36 +56,16 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ExperienceForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new experience</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<ExperienceForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,36 +88,16 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ExperienceForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing experience</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<ExperienceForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { interestItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
@@ -25,6 +17,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = interestItemSchema;
|
||||
|
||||
@@ -58,36 +51,16 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<InterestForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new interest</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<InterestForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,36 +83,16 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<InterestForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing interest</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<InterestForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { languageItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
@@ -21,6 +13,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
|
||||
const formSchema = languageItemSchema;
|
||||
|
||||
@@ -53,36 +46,16 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<LanguageForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Create a new language</Trans>}
|
||||
icon={<PlusIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
>
|
||||
<LanguageForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,36 +78,16 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<LanguageForm form={form} />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<SectionItemDialog
|
||||
title={<Trans>Update an existing language</Trans>}
|
||||
icon={<PencilSimpleLineIcon />}
|
||||
onSubmit={() => void form.handleSubmit()}
|
||||
onCancel={requestClose}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Save Changes</Trans>}
|
||||
>
|
||||
<LanguageForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user