mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-30 00:34:28 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c721fe5cbc | ||
|
|
b1d95e61de | ||
|
|
c7fc25b1a8 | ||
|
|
af751e4f94 | ||
|
|
4ae6d8476f | ||
|
|
540796cd67 | ||
|
|
be1acbc221 | ||
|
|
5c75147df3 | ||
|
|
5c1845b1a6 | ||
|
|
00a9721556 | ||
|
|
5a1cc6585c | ||
|
|
a27425e350 | ||
|
|
91b4c1e645 | ||
|
|
e99c1665d9 | ||
|
|
be43b4556b | ||
|
|
0d1bfd4e6b | ||
|
|
20c803e934 | ||
|
|
893df25aff | ||
|
|
6e7fc68068 | ||
|
|
a28e3baa61 | ||
|
|
9f9268f380 | ||
|
|
8416a92153 | ||
|
|
3f6e22addb | ||
|
|
25b70c24f1 | ||
|
|
da40422dfa | ||
|
|
e15edafbff | ||
|
|
d5b177aa89 | ||
|
|
d32227ff43 | ||
|
|
7a0d1e93f3 | ||
|
|
560956bbe6 | ||
|
|
7f458dc58d | ||
|
|
361480445f | ||
|
|
57fb23145c | ||
|
|
6207cbc026 | ||
|
|
a149e614a7 | ||
|
|
eab7534ea4 | ||
|
|
79a69c5507 | ||
|
|
70df113ee6 | ||
|
|
44e9a8a29f | ||
|
|
e47cb37ab9 | ||
|
|
02538836a9 | ||
|
|
22398a502b | ||
|
|
e00348ef84 | ||
|
|
8d17ec6583 | ||
|
|
e93a56d753 | ||
|
|
975cea84e3 | ||
|
|
34398a578b | ||
|
|
27efeab796 | ||
|
|
f5ec471318 | ||
|
|
376977a9f7 | ||
|
|
9b9d5c833c | ||
|
|
15448cad6a | ||
|
|
afd734dd61 | ||
|
|
493ef12a9a | ||
|
|
a5935dee0f | ||
|
|
5226f04e86 | ||
|
|
a1fb0597a3 | ||
|
|
a2a2c0a768 | ||
|
|
f2ec6a499f | ||
|
|
0fb81ad772 | ||
|
|
19470c8cd2 | ||
|
|
3f050e5213 | ||
|
|
91c4a2421c | ||
|
|
82d961241e | ||
|
|
f3a60432df | ||
|
|
0701f3b62a | ||
|
|
cf738b9306 | ||
|
|
fcc10c6b31 | ||
|
|
3e96605d4c | ||
|
|
7e35e8b657 | ||
|
|
8de15822fb | ||
|
|
e2099b9002 | ||
|
|
5762eb6a3e | ||
|
|
dfe75390cd | ||
|
|
439ae114f9 | ||
|
|
9b41edb43d | ||
|
|
d87c6758ab | ||
|
|
44fa2badb4 | ||
|
|
0abb5a07e6 | ||
|
|
a9a38ff5dc | ||
|
|
bf70705f1f | ||
|
|
332aa210c4 | ||
|
|
da6a9f2c78 | ||
|
|
4541cf1cdc | ||
|
|
27df724d2a | ||
|
|
bc09430fdf | ||
|
|
e936f93e3a | ||
|
|
3ba566506a | ||
|
|
a7c599b724 | ||
|
|
dbb0b179c3 | ||
|
|
fc634a202d | ||
|
|
7fab23870f | ||
|
|
20a8a3df9d | ||
|
|
e38e37383d | ||
|
|
d45116b2ba | ||
|
|
6ad4f13914 | ||
|
|
2f5d321051 | ||
|
|
57e9c8c487 | ||
|
|
09bc6ec521 | ||
|
|
50885176e0 | ||
|
|
cbeecf6596 | ||
|
|
ee970f2961 | ||
|
|
578a983209 | ||
|
|
617135466d | ||
|
|
fa4c8adf78 | ||
|
|
5b8ab33888 | ||
|
|
0ba44865c7 | ||
|
|
a4999c04af | ||
|
|
2a80e6a1df | ||
|
|
4c8cc5c016 | ||
|
|
d3735ebe27 | ||
|
|
8eab8fdaa0 | ||
|
|
fbb9938af6 | ||
|
|
5080fddf51 | ||
|
|
dfd2c77bc9 | ||
|
|
56c90947e4 | ||
|
|
ae2a1dac12 | ||
|
|
dcf1b28c22 | ||
|
|
f14d8ce693 | ||
|
|
2317a82106 | ||
|
|
a523e13bfd | ||
|
|
1be75240dd | ||
|
|
7275da7303 | ||
|
|
bc498449d3 | ||
|
|
3937f7ed2b | ||
|
|
d6de3f830f | ||
|
|
ef5ff30b13 | ||
|
|
37faf592b7 | ||
|
|
76bd1e80f7 | ||
|
|
042d076efa | ||
|
|
b9e4ab78ef | ||
|
|
90a9bb9cf1 | ||
|
|
5fb4976ec9 | ||
|
|
d6a9bc6c4b | ||
|
|
0dcdcd2960 | ||
|
|
e96a51f31c | ||
|
|
1507d869c7 | ||
|
|
b932711f08 | ||
|
|
1522794733 | ||
|
|
e00ff8ceca | ||
|
|
8e72311bc6 | ||
|
|
a8c70d784c | ||
|
|
0df7f21130 | ||
|
|
6852f586ea | ||
|
|
1414fecade | ||
|
|
c1d11236ae | ||
|
|
d09ad2cdc0 | ||
|
|
9ce5bacd22 | ||
|
|
1d761be05b | ||
|
|
c875541001 | ||
|
|
16f4d2c072 | ||
|
|
b491582637 | ||
|
|
c6a654191c | ||
|
|
8461aa65d5 | ||
|
|
b04eef1479 | ||
|
|
7bff6644d8 | ||
|
|
8da780c868 | ||
|
|
dd1e37e579 | ||
|
|
19b412d84d | ||
|
|
7eea6675c0 | ||
|
|
273e17c0d3 | ||
|
|
17cddbad65 | ||
|
|
7557ab13ab | ||
|
|
c66560ee12 | ||
|
|
24c882fa9f | ||
|
|
86fff7237f | ||
|
|
266bc291eb | ||
|
|
6ec4da7914 | ||
|
|
75e9446134 | ||
|
|
39e88dd365 | ||
|
|
3596102c63 | ||
|
|
c77684d317 | ||
|
|
62f8270b3e | ||
|
|
5b1297fa2b | ||
|
|
dd7623f11e | ||
|
|
63e8c3ca33 | ||
|
|
e62090cce0 | ||
|
|
0510c7103b | ||
|
|
1a5c5252d1 | ||
|
|
9df2a5287d | ||
|
|
6d8d8f6e55 | ||
|
|
22c60c64b6 | ||
|
|
affa1d6646 | ||
|
|
c71f3b0b92 | ||
|
|
6c4a4b2aa5 | ||
|
|
1294d3354a | ||
|
|
42fc78dca1 | ||
|
|
aa7af040fb | ||
|
|
5f63dc876b | ||
|
|
014ceee31f | ||
|
|
70dff5bf49 | ||
|
|
c5787fe155 | ||
|
|
286e165a60 | ||
|
|
00dafd0c68 | ||
|
|
d251d602fb | ||
|
|
e35ff83911 | ||
|
|
62b0a1d533 | ||
|
|
0a8fe05653 | ||
|
|
de7baa5faf | ||
|
|
48555f58e5 | ||
|
|
0daf868cd4 | ||
|
|
e574d4005f | ||
|
|
fda4e500b3 | ||
|
|
adfc9b527b | ||
|
|
71aadbd73d | ||
|
|
0713cf20d4 | ||
|
|
334ea48bc7 | ||
|
|
69c23211a0 | ||
|
|
143aaa741b | ||
|
|
e4cc6a8e57 | ||
|
|
92a0e3ddb8 | ||
|
|
4ebe9e5a67 | ||
|
|
0abee1048c | ||
|
|
83a407bc10 | ||
|
|
02973a1eb1 | ||
|
|
2e04e71f4a | ||
|
|
978cbaf1f3 | ||
|
|
3cd228bd84 | ||
|
|
64ac3ff328 | ||
|
|
846b7856a7 | ||
|
|
7a60a42a04 | ||
|
|
b0de64ad13 | ||
|
|
b321e01658 | ||
|
|
6a01207b6b | ||
|
|
2f6a8904e4 | ||
|
|
56c9eb2ff4 | ||
|
|
33103536ae | ||
|
|
a93e7bd190 | ||
|
|
4cd4b8c193 | ||
|
|
be9285aa33 | ||
|
|
6787175a8a | ||
|
|
42e83cc676 | ||
|
|
62f4532157 | ||
|
|
fabe22089d | ||
|
|
fa38f3e84a | ||
|
|
0606e0072b | ||
|
|
bdfb854602 | ||
|
|
05f094bd13 | ||
|
|
d1c301de83 | ||
|
|
e42af3cd04 | ||
|
|
f3375adecb | ||
|
|
d9e3289f69 | ||
|
|
7c08007a9d | ||
|
|
296f7951ec | ||
|
|
bf1a540fd1 | ||
|
|
e4a574ccd0 | ||
|
|
26ca5c29c3 | ||
|
|
2cd774dab7 | ||
|
|
9cbb30d3ba | ||
|
|
ed42f181ca | ||
|
|
21cadd76fe | ||
|
|
4c771307e0 | ||
|
|
6d54ffa88b | ||
|
|
5042ad9d1f | ||
|
|
3b82aa90f3 | ||
|
|
524a7a6f4e | ||
|
|
36a16f6483 | ||
|
|
e320f3a920 | ||
|
|
d5891ff035 | ||
|
|
891ce60270 | ||
|
|
1b0bb067b6 | ||
|
|
2b3af1f7b7 | ||
|
|
ced765e229 | ||
|
|
023cb4a594 | ||
|
|
a43538c919 | ||
|
|
faba604813 | ||
|
|
f8b3437e33 | ||
|
|
4ac16df3d6 | ||
|
|
4ae3e1a230 | ||
|
|
dcccbcfa9e | ||
|
|
92a3916e76 | ||
|
|
6321e99b9e | ||
|
|
94c953005e | ||
|
|
cf957692fc | ||
|
|
64492ce2f1 | ||
|
|
4165c10a71 | ||
|
|
ef6961c9eb | ||
|
|
13787db34a | ||
|
|
f64daeb89e | ||
|
|
6e447d1bcc | ||
|
|
148d564fe8 | ||
|
|
50ba37a27f | ||
|
|
51c366310e | ||
|
|
4e58ea6c8a | ||
|
|
fd892b0e14 | ||
|
|
dc4830d41b | ||
|
|
480e9a2612 | ||
|
|
4cf5cdd181 | ||
|
|
b9894c687c | ||
|
|
9048702cb8 | ||
|
|
dd94d070af | ||
|
|
e035088269 | ||
|
|
1fbe89bc01 | ||
|
|
4b071f2db7 | ||
|
|
80ca3a8875 | ||
|
|
e95be536fa | ||
|
|
d6287dbd65 | ||
|
|
47abce351b | ||
|
|
8f6c65b7fd | ||
|
|
5a88ac12bf | ||
|
|
118004b3d3 | ||
|
|
6a8dd480cb | ||
|
|
5d8126d4b0 | ||
|
|
8566a9fd0f | ||
|
|
0d0bc61582 | ||
|
|
e3af637100 | ||
|
|
f752145c57 | ||
|
|
b87f200767 | ||
|
|
73ec8b2ffb | ||
|
|
107537999f | ||
|
|
e1bccbcc93 | ||
|
|
54e256be09 | ||
|
|
b26d510294 | ||
|
|
623ca5c675 | ||
|
|
76e00ae019 | ||
|
|
ff0ef12ecc | ||
|
|
1bcb2e2af6 |
@@ -0,0 +1,55 @@
|
||||
# design-sync notes — @reactive-resume/ui
|
||||
|
||||
Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f1ea950517`).
|
||||
|
||||
## Repo shape / build
|
||||
|
||||
- `@reactive-resume/ui` is **source-consumed** (pnpm workspace, no `dist`, exports point at `src/components/*.tsx`). Runs in the converter's **synth-entry mode** (no `--entry`).
|
||||
- `buildCmd` = `node .design-sync/build-css.mjs`. That one script does three things, all required before every converter run:
|
||||
1. Creates the workspace **self-symlink** `packages/ui/node_modules/@reactive-resume/ui -> ../../../ui` (pnpm doesn't self-install it; the converter resolves the DS as `node_modules/<pkg>` and esbuild needs it for `@reactive-resume/ui/components/*` self-imports).
|
||||
2. Emits real **`.d.ts`** to `packages/ui/dist/types` via `tsc -p packages/ui/tsconfig.emit.json`. Without this, synth-entry mode gives weak `{[key]: unknown}` prop contracts; with it the converter's `findTypesRoot` picks up `dist/types` and every component gets real props (variant/size unions, inherited Base UI props).
|
||||
3. Compiles Tailwind v4 `globals.css` → self-contained `packages/ui/.ds-compiled.css` (`cfg.cssEntry`): inlines the IBM Plex Sans latin variable woff2 as a data-URI and strips all other `@font-face` (extra scripts + the Phosphor icon web font, which previews don't use — components render Phosphor as inline React SVGs). This is why previews are fully styled with tokens + brand font and there are zero dangling font URLs.
|
||||
- CSS entry scans `.design-sync/tw-entry.css` which `@import`s globals.css and adds `@source "./previews/*.tsx"` so utility classes used in authored previews are compiled. **Preview layout wrappers use inline styles** anyway (so subagents needn't recompile the shared CSS); only component-level utility classes need the recompile.
|
||||
|
||||
## Card scope
|
||||
|
||||
- The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely.
|
||||
- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster.
|
||||
|
||||
## Preview authoring conventions (calibrated on Button / Alert / Dialog)
|
||||
|
||||
- Import naturally: `import { Button } from "@reactive-resume/ui/components/button"` — converter rule 2 redirects any exported-component module to `window.RRUI`, and sub-parts resolve too.
|
||||
- Icons: `@phosphor-icons/react` with the `*Icon` suffix (e.g. `PlusIcon`, `TrashIcon`, `WarningIcon`). Bundles into the preview.
|
||||
- Base UI compose pattern: `render={<Button variant="outline" />}` on `*.Trigger` / `*.Close` etc.
|
||||
- Layout wrappers: inline `style={{ display:"flex", gap, padding }}` — not Tailwind (keeps fan-out from needing CSS recompiles).
|
||||
- **Overlays** (Dialog, and expect the same for AlertDialog/Sheet/Popover/HoverCard/DropdownMenu/ContextMenu/Tooltip/Command-dialog): render open via `defaultOpen`, and set `cfg.overrides.<Name> = {cardMode:"single", primaryStory:"<export>", viewport:"WxH"}`. Use viewport width ≥ 640 so `sm:` breakpoint styles (e.g. horizontal dialog footer) engage — Dialog uses `760x440`.
|
||||
- Realistic resume-app content (resumes, sections, publish/export/share), never foo/bar.
|
||||
|
||||
## Component composition notes (from the authoring wave)
|
||||
|
||||
- **Real `.d.ts` contracts require the barrel** (see build step 2 + `publishConfig.types`). Base UI prop names differ from Radix/native: Switch `defaultChecked`+`size`; Toggle `defaultPressed`+`variant`+`size`; Slider `defaultValue` array (`[n]` single / `[a,b]` range). Use uncontrolled `default*` props in previews to avoid controlled-without-onChange warnings.
|
||||
- **BrandIcon renders the app's own logo/icon** (`variant="logo"|"icon"`), NOT a social/brand-slug icon. It `<img src>`s `/logo/*.svg` + `/icon/*.svg`, which the preview server (serving `ds-bundle/`) 404s. The BrandIcon preview inlines the real `apps/web/public/{logo,icon}/light.svg` as base64 `src` overrides (component spreads `{...props}` after its own `src`, so the override wins).
|
||||
- **Overlays** handled by the orchestrator with `cfg.overrides` (cardMode single + primaryStory Open + viewport): Dialog, AlertDialog, Sheet, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, ComboboxRoot. Command renders **inline** (cmdk, no overlay); Sidebar uses `collapsible="none"` to render inline (default offcanvas is fixed-positioned); Toaster fires a `duration:Infinity` toast on mount.
|
||||
- **Providers composed in-preview** (no cfg.provider): Tooltip→TooltipProvider, Sidebar→SidebarProvider, MessageScroller→MessageScrollerProvider (+ explicit container height — Root is `size-full min-h-0` and collapses otherwise), FormItem carries its own context.
|
||||
- **Accordion** opens statically via `defaultValue={[...itemValues]}` (the `--accordion-panel-height` warn is a non-issue — panels measure fine). **Tabs** via `defaultValue`. **ScrollArea/ResizableGroup/InputGroup** need an inline container height/width. **Separator** vertical needs an explicit height.
|
||||
- Chat/attachment components (Attachment, Bubble, Message, MessageScroller, Marker) are all used only in `apps/web/src/routes/agent/-components/agent-chat.tsx` — the canonical composition source.
|
||||
|
||||
## Build/verify gotchas (learned the hard way)
|
||||
|
||||
- **A full `package-build` takes ~3-4 minutes** — not a hang. `@phosphor-icons/react` is a giant barrel, so each icon-importing preview costs ~10-20s of esbuild parse, and 30+ authored previews compile serially. Always run it in a real background task (not a 120s-capped foreground shell) and wait for completion.
|
||||
- **Do NOT add a barrel `index.d.ts` + `publishConfig.types`** to get rich props for inline-param-typed components: it makes ts-morph resolve all 200+ inline Base UI param types and hangs the build for many minutes. Tried and reverted. Result: components with a named `<Name>Props` source type (Button) get real props; the rest get honest `{[key]: unknown}`.
|
||||
- **Base UI menu Labels must be inside a Group**: `DropdownMenuLabel`/`ContextMenuLabel` throw `MenuGroupContext is missing` unless wrapped in `DropdownMenuGroup`/`ContextMenuGroup`. Same likely for other `*Label`/`*GroupLabel` menu parts.
|
||||
- **`[RENDER_THIN]` (height 0px) is benign for fixed-position overlays** (Dialog, AlertDialog, Sheet): the content is `position:fixed` so it measures 0 in normal flow, but `rootEmpty:false` and the screenshot is correct. Confirmed via review sheets — not a failure.
|
||||
- **`[GRID_OVERFLOW]` wide** → `cfg.overrides.<Name> = {cardMode:"column"}` applied to: Accordion, Attachment, Bubble, FormItem, InputGroup, Marker, Message, ResizableGroup, Tabs, Textarea. Toaster (portal escape) → `{cardMode:"single", primaryStory:"Notification"}`.
|
||||
|
||||
## Known render warns (triaged, not failures)
|
||||
|
||||
- `[TOKENS_MISSING]`: `--active-tab-{top,left,height,width}` (Base UI tab indicator sets these at runtime), `--accordion-panel-height` (Base UI accordion runtime), `--tw` (Tailwind internal), plus app-level `--resume-preview-page-gap` / `--page-primary-color` (defined by apps/web, not this package). All expected absent from the shipped stylesheet — components set them at runtime. Do not chase.
|
||||
- `--font-heading` is referenced (DialogTitle `font-heading`) but not defined in the UI package tokens (app-level). Falls back to `--font-body` (IBM Plex). Cosmetic only.
|
||||
- Unauthored primitives render near-empty floor cards (`[RENDER_BLANK]` for empty Button/Input/etc.) — resolved once authored.
|
||||
|
||||
## Re-sync risks
|
||||
|
||||
- `packages/ui/dist/types`, `packages/ui/.ds-compiled.css`, `packages/ui/.ds-tw-raw.css`, and the self-symlink are all gitignored build artifacts regenerated by `buildCmd` — always run `node .design-sync/build-css.mjs` before the converter/driver.
|
||||
- The inlined IBM Plex font path in `build-css.mjs` is pinned to `@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2`; if that dep moves, the font inline breaks (previews fall back to system sans).
|
||||
- `tsconfig.emit.json` is committed; if the package adds a real build later, prefer pointing the converter at that dist and drop the emit step.
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// design-sync CSS build: compile the UI package's Tailwind v4 globals.css to
|
||||
// static CSS, then make it self-contained for preview rendering by inlining the
|
||||
// IBM Plex Sans (latin) variable webfont as a data-URI and dropping the other
|
||||
// @font-face rules (extra scripts + the Phosphor icon font, which previews
|
||||
// don't use — components render Phosphor as inline React SVGs).
|
||||
//
|
||||
// Output: packages/ui/.ds-compiled.css (cfg.cssEntry, bounded to the package)
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = resolve(here, "..");
|
||||
|
||||
// pnpm doesn't self-install the workspace package into its own node_modules,
|
||||
// but the design-sync converter resolves the DS as node_modules/<pkg>. Create
|
||||
// the self-symlink so PKG_DIR resolves and esbuild finds @reactive-resume/ui/*
|
||||
// self-imports. Mirrors the sibling symlinks pnpm already writes (utils, config).
|
||||
const selfLink = resolve(repo, "packages/ui/node_modules/@reactive-resume/ui");
|
||||
if (!existsSync(selfLink)) symlinkSync("../../../ui", selfLink);
|
||||
|
||||
// Emit real .d.ts declarations (the package is source-consumed with no build).
|
||||
// The converter's findTypesRoot picks up dist/types, giving components real
|
||||
// prop contracts (variant/size unions, inherited Base UI props) instead of the
|
||||
// weak `{[key]: unknown}` synth-entry fallback.
|
||||
execFileSync(resolve(repo, "node_modules/.bin/tsc"), ["-p", "tsconfig.emit.json"], {
|
||||
cwd: resolve(repo, "packages/ui"),
|
||||
stdio: "inherit",
|
||||
});
|
||||
// NOTE: a barrel index.d.ts + publishConfig.types was tried to give the prop
|
||||
// extractor an entry for components with inline param types — but resolving all
|
||||
// 200+ inline Base UI param types through ts-morph's checker hangs the build
|
||||
// (many minutes). Reverted. Components with a named <Name>Props source type
|
||||
// (e.g. Button) still extract real props from dist/types; the rest fall back to
|
||||
// the honest `{[key]: unknown}` contract, with usage carried by the preview +
|
||||
// .prompt.md. See .design-sync/NOTES.md "Re-sync risks".
|
||||
const cli = resolve(repo, ".ds-sync/node_modules/.bin/tailwindcss");
|
||||
const entry = resolve(here, "tw-entry.css");
|
||||
const tmp = resolve(repo, "packages/ui/.ds-tw-raw.css");
|
||||
const out = resolve(repo, "packages/ui/.ds-compiled.css");
|
||||
const font = resolve(
|
||||
repo,
|
||||
"packages/ui/node_modules/@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2",
|
||||
);
|
||||
|
||||
execFileSync(cli, ["-i", entry, "-o", tmp], { stdio: "inherit" });
|
||||
|
||||
let css = readFileSync(tmp, "utf8");
|
||||
css = css.replace(/@font-face\s*\{[^}]*\}/g, ""); // drop all shipped @font-face
|
||||
const b64 = readFileSync(font).toString("base64");
|
||||
const face = `@font-face{font-family:"IBM Plex Sans Variable";font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,${b64}) format("woff2-variations")}\n`;
|
||||
writeFileSync(out, face + css);
|
||||
console.error(` build-css: wrote ${out} (${(Buffer.byteLength(face + css) / 1024).toFixed(0)} KB, font inlined)`);
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"projectId": "3c0f6556-050a-41e5-9886-c3f1ea950517",
|
||||
"pkg": "@reactive-resume/ui",
|
||||
"globalName": "RRUI",
|
||||
"shape": "package",
|
||||
"buildCmd": "node .design-sync/build-css.mjs",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"cssEntry": ".ds-compiled.css",
|
||||
"componentSrcMap": {
|
||||
"AccordionContent": null,
|
||||
"AccordionItem": null,
|
||||
"AccordionTrigger": null,
|
||||
"AlertAction": null,
|
||||
"AlertDescription": null,
|
||||
"AlertDialogAction": null,
|
||||
"AlertDialogCancel": null,
|
||||
"AlertDialogContent": null,
|
||||
"AlertDialogDescription": null,
|
||||
"AlertDialogFooter": null,
|
||||
"AlertDialogHeader": null,
|
||||
"AlertDialogMedia": null,
|
||||
"AlertDialogOverlay": null,
|
||||
"AlertDialogPortal": null,
|
||||
"AlertDialogTitle": null,
|
||||
"AlertDialogTrigger": null,
|
||||
"AlertTitle": null,
|
||||
"AttachmentAction": null,
|
||||
"AttachmentActions": null,
|
||||
"AttachmentContent": null,
|
||||
"AttachmentDescription": null,
|
||||
"AttachmentGroup": null,
|
||||
"AttachmentMedia": null,
|
||||
"AttachmentTitle": null,
|
||||
"AttachmentTrigger": null,
|
||||
"AvatarBadge": null,
|
||||
"AvatarFallback": null,
|
||||
"AvatarGroup": null,
|
||||
"AvatarGroupCount": null,
|
||||
"AvatarImage": null,
|
||||
"BubbleContent": null,
|
||||
"BubbleGroup": null,
|
||||
"BubbleReactions": null,
|
||||
"ButtonGroupSeparator": null,
|
||||
"ButtonGroupText": null,
|
||||
"ComboboxChip": null,
|
||||
"ComboboxChips": null,
|
||||
"ComboboxChipsInput": null,
|
||||
"ComboboxClear": null,
|
||||
"ComboboxCollection": null,
|
||||
"ComboboxContent": null,
|
||||
"ComboboxEmpty": null,
|
||||
"ComboboxGroup": null,
|
||||
"ComboboxInput": null,
|
||||
"ComboboxItem": null,
|
||||
"ComboboxLabel": null,
|
||||
"ComboboxList": null,
|
||||
"ComboboxSeparator": null,
|
||||
"ComboboxTrigger": null,
|
||||
"ComboboxValue": null,
|
||||
"CommandDialog": null,
|
||||
"CommandEmpty": null,
|
||||
"CommandGroup": null,
|
||||
"CommandInput": null,
|
||||
"CommandItem": null,
|
||||
"CommandList": null,
|
||||
"CommandSeparator": null,
|
||||
"CommandShortcut": null,
|
||||
"ContextMenuCheckboxItem": null,
|
||||
"ContextMenuContent": null,
|
||||
"ContextMenuGroup": null,
|
||||
"ContextMenuItem": null,
|
||||
"ContextMenuLabel": null,
|
||||
"ContextMenuPortal": null,
|
||||
"ContextMenuRadioGroup": null,
|
||||
"ContextMenuRadioItem": null,
|
||||
"ContextMenuSeparator": null,
|
||||
"ContextMenuShortcut": null,
|
||||
"ContextMenuSub": null,
|
||||
"ContextMenuSubContent": null,
|
||||
"ContextMenuSubTrigger": null,
|
||||
"ContextMenuTrigger": null,
|
||||
"DialogClose": null,
|
||||
"DialogContent": null,
|
||||
"DialogDescription": null,
|
||||
"DialogFooter": null,
|
||||
"DialogHeader": null,
|
||||
"DialogOverlay": null,
|
||||
"DialogPortal": null,
|
||||
"DialogTitle": null,
|
||||
"DialogTrigger": null,
|
||||
"DropdownMenuCheckboxItem": null,
|
||||
"DropdownMenuContent": null,
|
||||
"DropdownMenuGroup": null,
|
||||
"DropdownMenuItem": null,
|
||||
"DropdownMenuLabel": null,
|
||||
"DropdownMenuPortal": null,
|
||||
"DropdownMenuRadioGroup": null,
|
||||
"DropdownMenuRadioItem": null,
|
||||
"DropdownMenuSeparator": null,
|
||||
"DropdownMenuShortcut": null,
|
||||
"DropdownMenuSub": null,
|
||||
"DropdownMenuSubContent": null,
|
||||
"DropdownMenuSubTrigger": null,
|
||||
"DropdownMenuTrigger": null,
|
||||
"FormControl": null,
|
||||
"FormDescription": null,
|
||||
"FormLabel": null,
|
||||
"FormMessage": null,
|
||||
"HoverCardContent": null,
|
||||
"HoverCardTrigger": null,
|
||||
"InputGroupAddon": null,
|
||||
"InputGroupButton": null,
|
||||
"InputGroupInput": null,
|
||||
"InputGroupText": null,
|
||||
"InputGroupTextarea": null,
|
||||
"KbdGroup": null,
|
||||
"MarkerContent": null,
|
||||
"MarkerIcon": null,
|
||||
"MessageAvatar": null,
|
||||
"MessageContent": null,
|
||||
"MessageFooter": null,
|
||||
"MessageGroup": null,
|
||||
"MessageHeader": null,
|
||||
"MessageScrollerButton": null,
|
||||
"MessageScrollerContent": null,
|
||||
"MessageScrollerItem": null,
|
||||
"MessageScrollerProvider": null,
|
||||
"MessageScrollerViewport": null,
|
||||
"PopoverContent": null,
|
||||
"PopoverDescription": null,
|
||||
"PopoverHeader": null,
|
||||
"PopoverTitle": null,
|
||||
"PopoverTrigger": null,
|
||||
"ResizablePanel": null,
|
||||
"ResizableSeparator": null,
|
||||
"ScrollBar": null,
|
||||
"SheetClose": null,
|
||||
"SheetContent": null,
|
||||
"SheetDescription": null,
|
||||
"SheetFooter": null,
|
||||
"SheetHeader": null,
|
||||
"SheetTitle": null,
|
||||
"SheetTrigger": null,
|
||||
"SidebarContent": null,
|
||||
"SidebarFooter": null,
|
||||
"SidebarGroup": null,
|
||||
"SidebarGroupAction": null,
|
||||
"SidebarGroupContent": null,
|
||||
"SidebarGroupLabel": null,
|
||||
"SidebarHeader": null,
|
||||
"SidebarInput": null,
|
||||
"SidebarInset": null,
|
||||
"SidebarMenu": null,
|
||||
"SidebarMenuAction": null,
|
||||
"SidebarMenuBadge": null,
|
||||
"SidebarMenuButton": null,
|
||||
"SidebarMenuItem": null,
|
||||
"SidebarMenuSkeleton": null,
|
||||
"SidebarMenuSub": null,
|
||||
"SidebarMenuSubButton": null,
|
||||
"SidebarMenuSubItem": null,
|
||||
"SidebarProvider": null,
|
||||
"SidebarRail": null,
|
||||
"SidebarSeparator": null,
|
||||
"SidebarTrigger": null,
|
||||
"TabsContent": null,
|
||||
"TabsIndicator": null,
|
||||
"TabsList": null,
|
||||
"TabsTrigger": null,
|
||||
"TooltipContent": null,
|
||||
"TooltipProvider": null,
|
||||
"TooltipTrigger": null
|
||||
},
|
||||
"overrides": {
|
||||
"Dialog": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "760x440"
|
||||
},
|
||||
"AlertDialog": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "640x460"
|
||||
},
|
||||
"Sheet": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "760x480"
|
||||
},
|
||||
"Popover": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "420x340"
|
||||
},
|
||||
"Tooltip": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "360x260"
|
||||
},
|
||||
"HoverCard": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x320"
|
||||
},
|
||||
"DropdownMenu": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x360"
|
||||
},
|
||||
"ContextMenu": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x340"
|
||||
},
|
||||
"ComboboxRoot": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "420x360"
|
||||
},
|
||||
"Accordion": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Attachment": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Bubble": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"FormItem": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"InputGroup": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Marker": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Message": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"ResizableGroup": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Tabs": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Textarea": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Toaster": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Notification"
|
||||
}
|
||||
},
|
||||
"readmeHeader": ".design-sync/conventions.md"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Reactive Resume UI — how to build with it
|
||||
|
||||
This is `@reactive-resume/ui`: a shadcn-style React component library built on **Base UI**
|
||||
primitives and **Tailwind CSS v4**. Every component is real upstream code, bundled to the
|
||||
`window.RRUI` global; the 39 cards are the primary components, but all their compound
|
||||
sub-parts (e.g. `DialogContent`, `AccordionItem`, `SidebarMenuButton`) are also on `RRUI`.
|
||||
|
||||
## Setup & wrapping
|
||||
|
||||
- **No global provider is required.** All design tokens live on `:root` in `styles.css` (loaded
|
||||
for you), so components are styled out of the box. For dark mode, add `class="dark"` to a
|
||||
wrapping element — the same tokens flip to their dark values.
|
||||
- **A few components need their own provider — wrap only where you use them:**
|
||||
- `Tooltip*` → wrap in `RRUI.TooltipProvider`.
|
||||
- `Sidebar*` → wrap in `RRUI.SidebarProvider`.
|
||||
- `MessageScroller*` → wrap in `RRUI.MessageScrollerProvider` and give it a bounded height.
|
||||
- Form fields → `RRUI.FormItem` provides the field context for `FormLabel`/`FormControl`/`FormMessage`.
|
||||
- **Compose compound components** from their parts, e.g. `Dialog` = `DialogTrigger` + `DialogContent`
|
||||
(+ `DialogHeader`/`DialogTitle`/`DialogDescription`/`DialogFooter`). Overlay parts (Dialog, Sheet,
|
||||
Popover, DropdownMenu, ContextMenu, Tooltip, HoverCard) render into a portal. Menu labels must sit
|
||||
inside a `*Group` (`DropdownMenuGroup`, `ContextMenuGroup`).
|
||||
- Icons come from `@phosphor-icons/react` (the `*Icon` suffix, e.g. `PlusIcon`).
|
||||
|
||||
## Styling idiom — Tailwind utilities on semantic tokens
|
||||
|
||||
Components style themselves; for **your own** layout and surfaces, use Tailwind utility classes
|
||||
bound to the design system's **semantic color tokens** (never raw hex — these adapt to light/dark):
|
||||
|
||||
| Purpose | Utilities |
|
||||
|---|---|
|
||||
| Surfaces | `bg-background`, `bg-card`, `bg-popover`, `bg-muted`, `bg-sidebar` |
|
||||
| Brand / actions | `bg-primary` + `text-primary-foreground`, `bg-secondary` + `text-secondary-foreground` |
|
||||
| Accents / hover | `bg-accent` + `text-accent-foreground`, `hover:bg-muted` |
|
||||
| Danger | `bg-destructive`, `text-destructive` |
|
||||
| Text | `text-foreground` (primary), `text-muted-foreground` (secondary) |
|
||||
| Borders / focus | `border`, `border-input`, `ring-ring`, `outline-ring` |
|
||||
| Radius | `rounded-md`, `rounded-lg` (driven by `--radius`) |
|
||||
|
||||
Each token is also a CSS variable (`var(--primary)`, `var(--muted-foreground)`, `var(--border)`,
|
||||
`var(--radius)`, `--font-body` = IBM Plex Sans) if you need it in inline styles.
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
- **Styling:** `styles.css` and its `@import` closure (`_ds_bundle.css` = component styles; the
|
||||
token definitions on `:root`/`.dark`). Read these before inventing a class or color.
|
||||
- **Per component:** `components/<group>/<Name>/<Name>.prompt.md` (usage) and `<Name>.d.ts` (props —
|
||||
variant/size unions where a named type exists; some fall back to a permissive shape).
|
||||
|
||||
## Idiomatic snippet
|
||||
|
||||
```jsx
|
||||
// A confirm action, styled with the DS's own tokens for the surrounding layout.
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground">Publish this resume to your public profile?</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RRUI.Button variant="outline">Cancel</RRUI.Button>
|
||||
<RRUI.Button>Publish</RRUI.Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
import type * as React from "react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
|
||||
const wrap: React.CSSProperties = { width: 420, padding: 16 };
|
||||
|
||||
// Open by default so the panel content is visible in the card (Base UI accordion
|
||||
// is uncontrolled via defaultValue, matching item `value` props).
|
||||
export const Sections = () => (
|
||||
<div style={wrap}>
|
||||
<Accordion defaultValue={["experience"]}>
|
||||
<AccordionItem value="experience">
|
||||
<AccordionTrigger>Work Experience</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Senior Product Designer · Framer — 2021 to Present</p>
|
||||
<p>Led the redesign of the onboarding flow, lifting activation by 24% across web and mobile.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="education">
|
||||
<AccordionTrigger>Education</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>B.Des in Interaction Design · Rhode Island School of Design</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="skills">
|
||||
<AccordionTrigger>Skills</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Figma, prototyping, design systems, user research, and front-end handoff.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MultipleOpen = () => (
|
||||
<div style={wrap}>
|
||||
<Accordion multiple defaultValue={["summary", "certifications"]}>
|
||||
<AccordionItem value="summary">
|
||||
<AccordionTrigger>Professional Summary</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Full-stack engineer with eight years shipping resilient TypeScript services and design systems.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="certifications">
|
||||
<AccordionTrigger>Certifications</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>AWS Solutions Architect · Professional</p>
|
||||
<p>Certified Kubernetes Administrator</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import type * as React from "react";
|
||||
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, maxWidth: 540 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={wrap}>
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Resume saved</AlertTitle>
|
||||
<AlertDescription>Your changes were saved automatically and synced to your account.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Destructive = () => (
|
||||
<div style={wrap}>
|
||||
<Alert variant="destructive">
|
||||
<WarningIcon />
|
||||
<AlertTitle>Export failed</AlertTitle>
|
||||
<AlertDescription>We couldn't generate your PDF. Check your connection and try again.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithAction = () => (
|
||||
<div style={wrap}>
|
||||
<Alert>
|
||||
<AlertTitle>Unsaved changes</AlertTitle>
|
||||
<AlertDescription>You have edits that haven't been published to your public resume yet.</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button size="sm">Publish</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { WarningIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@reactive-resume/ui/components/alert-dialog";
|
||||
|
||||
// Overlay — rendered open (defaultOpen). cfg.overrides.AlertDialog pins
|
||||
// cardMode: single + viewport (content is fixed-positioned, centred).
|
||||
export const Open = () => (
|
||||
<AlertDialog defaultOpen>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia>
|
||||
<WarningIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Delete this resume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“Software Engineer” and its entire version history will be permanently removed. This action can’t be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive">Delete resume</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import type * as React from "react";
|
||||
import { DownloadSimpleIcon, FileDocIcon, FilePdfIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@reactive-resume/ui/components/attachment";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
|
||||
|
||||
export const WithActions = () => (
|
||||
<div style={wrap}>
|
||||
<Attachment>
|
||||
<AttachmentMedia>
|
||||
<FilePdfIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Ansel_Bradford_Resume.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>248 KB · PDF</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Download">
|
||||
<DownloadSimpleIcon />
|
||||
</AttachmentAction>
|
||||
<AttachmentAction aria-label="Remove">
|
||||
<TrashIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const States = () => (
|
||||
<div style={wrap}>
|
||||
<Attachment size="sm" state="uploading">
|
||||
<AttachmentMedia>
|
||||
<FileDocIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
|
||||
<AttachmentDescription>Uploading…</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment size="sm" state="error">
|
||||
<AttachmentMedia>
|
||||
<WarningIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>portfolio-2024.zip</AttachmentTitle>
|
||||
<AttachmentDescription>Upload failed · file too large</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Group = () => (
|
||||
<div style={{ padding: 16, width: 360 }}>
|
||||
<AttachmentGroup>
|
||||
<Attachment orientation="vertical" size="sm">
|
||||
<AttachmentMedia>
|
||||
<FilePdfIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Resume.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>248 KB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment orientation="vertical" size="sm">
|
||||
<AttachmentMedia>
|
||||
<FileDocIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
|
||||
<AttachmentDescription>19 KB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
</AttachmentGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type * as React from "react";
|
||||
import { CheckIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from "@reactive-resume/ui/components/avatar";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 16, padding: 20 };
|
||||
|
||||
export const Fallback = () => (
|
||||
<div style={row}>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>MK</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithStatus = () => (
|
||||
<div style={row}>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
<AvatarBadge>
|
||||
<CheckIcon weight="bold" />
|
||||
</AvatarBadge>
|
||||
</Avatar>
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>SR</AvatarFallback>
|
||||
<AvatarBadge />
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size="default">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Group = () => (
|
||||
<div style={row}>
|
||||
<AvatarGroup>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>MK</AvatarFallback>
|
||||
</Avatar>
|
||||
<AvatarGroupCount>+5</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import type * as React from "react";
|
||||
import { CheckCircleIcon, PencilSimpleIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, padding: 20 };
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={row}>
|
||||
<Badge>Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Destructive</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const StatusLabels = () => (
|
||||
<div style={row}>
|
||||
<Badge variant="secondary">
|
||||
<CheckCircleIcon weight="fill" data-icon="inline-start" />
|
||||
Published
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
<PencilSimpleIcon data-icon="inline-start" />
|
||||
Draft
|
||||
</Badge>
|
||||
<Badge>
|
||||
<SparkleIcon weight="fill" data-icon="inline-start" />
|
||||
Pro
|
||||
</Badge>
|
||||
<Badge variant="destructive">Expired</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Counts = () => (
|
||||
<div style={row}>
|
||||
<Badge>12</Badge>
|
||||
<Badge variant="secondary">New</Badge>
|
||||
<Badge variant="outline">v5.2</Badge>
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
||||
import type * as React from "react";
|
||||
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@reactive-resume/ui/components/bubble";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8, padding: 16, width: 420 };
|
||||
|
||||
export const Conversation = () => (
|
||||
<div style={wrap}>
|
||||
<BubbleGroup>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Can you make my summary sound more senior without exaggerating?</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted" align="start">
|
||||
<BubbleContent>
|
||||
I tightened it to lead with scope and outcomes. Want me to mirror that tone in your experience bullets too?
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Yes, keep it concise.</BubbleContent>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={wrap}>
|
||||
<Bubble variant="default" align="end">
|
||||
<BubbleContent>Applied 3 edits to your resume.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="tinted" align="start">
|
||||
<BubbleContent>I emphasized measurable launch outcomes in your last role.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="outline" align="start">
|
||||
<BubbleContent>Draft saved — publish when you're ready.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="destructive" align="start">
|
||||
<BubbleContent>Couldn't reach the AI provider. Retry?</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithReactions = () => (
|
||||
<div style={{ padding: 24, width: 420 }}>
|
||||
<Bubble variant="secondary" align="start">
|
||||
<BubbleContent>Rewrote your headline to target a Senior Product Manager role.</BubbleContent>
|
||||
<BubbleReactions>👍 2</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type * as React from "react";
|
||||
import { ArrowRightIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 16 };
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={row}>
|
||||
<Button>Save changes</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="link">Learn more</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Button size="xs">Extra small</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithIcons = () => (
|
||||
<div style={row}>
|
||||
<Button>
|
||||
<PlusIcon /> Add section
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Continue <ArrowRightIcon />
|
||||
</Button>
|
||||
<Button variant="destructive">
|
||||
<TrashIcon /> Remove
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" aria-label="Add section">
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={row}>
|
||||
<Button disabled>Saving…</Button>
|
||||
<Button variant="outline" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
AlignCenterHorizontalIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
ArrowClockwiseIcon,
|
||||
ArrowCounterClockwiseIcon,
|
||||
TextBIcon,
|
||||
TextItalicIcon,
|
||||
TextUnderlineIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@reactive-resume/ui/components/button-group";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16 };
|
||||
|
||||
export const Formatting = () => (
|
||||
<div style={wrap}>
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="icon" aria-label="Bold">
|
||||
<TextBIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Italic">
|
||||
<TextItalicIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Underline">
|
||||
<TextUnderlineIcon />
|
||||
</Button>
|
||||
<ButtonGroupSeparator />
|
||||
<Button variant="outline" size="icon" aria-label="Align left">
|
||||
<AlignLeftIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Align center">
|
||||
<AlignCenterHorizontalIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Align right">
|
||||
<AlignRightIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="outline">
|
||||
<ArrowCounterClockwiseIcon /> Undo
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<ArrowClockwiseIcon /> Redo
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithText = () => (
|
||||
<div style={wrap}>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupText>Zoom</ButtonGroupText>
|
||||
<Button variant="outline">50%</Button>
|
||||
<Button variant="outline">100%</Button>
|
||||
<Button variant="outline">150%</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup orientation="vertical">
|
||||
<Button variant="outline">Export PDF</Button>
|
||||
<Button variant="outline">Export DOCX</Button>
|
||||
<Button variant="outline">Copy link</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
} from "@reactive-resume/ui/components/combobox";
|
||||
|
||||
const skills = ["TypeScript", "React", "Node.js", "GraphQL", "PostgreSQL", "Kubernetes"];
|
||||
|
||||
// Base UI Combobox — items passed to Root, rendered open (defaultOpen).
|
||||
// cfg.overrides.ComboboxRoot pins cardMode: single + viewport with room below.
|
||||
export const Open = () => (
|
||||
<div style={{ width: 320, padding: 16, paddingBottom: 200 }}>
|
||||
<ComboboxRoot items={skills} defaultOpen>
|
||||
<ComboboxInput placeholder="Add a skill…" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No skills found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: string) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DownloadSimpleIcon, GearIcon, PlusIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@reactive-resume/ui/components/command";
|
||||
|
||||
// Command renders inline (cmdk) — a searchable command palette. No overlay.
|
||||
export const Palette = () => (
|
||||
<div style={{ width: 400, padding: 16 }}>
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Type a command or search…" />
|
||||
<CommandList>
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem>
|
||||
<PlusIcon /> New resume
|
||||
<CommandShortcut>⌘N</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<DownloadSimpleIcon /> Export as PDF
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem>
|
||||
<UserIcon /> Profile
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<GearIcon /> Settings
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/context-menu";
|
||||
|
||||
// Right-click menu — rendered open (defaultOpen) so the card shows the menu.
|
||||
// cfg.overrides.ContextMenu pins cardMode: single + viewport.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 24, paddingBottom: 160 }}>
|
||||
<ContextMenu defaultOpen>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: 240,
|
||||
height: 96,
|
||||
border: "1px dashed var(--border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Right-click a resume card
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel>Software Engineer</ContextMenuLabel>
|
||||
<ContextMenuItem>Open</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Rename
|
||||
<ContextMenuShortcut>F2</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
|
||||
// Overlay component — rendered open (defaultOpen) so the card shows the real
|
||||
// dialog surface. cfg.overrides.Dialog pins cardMode: single + a viewport for
|
||||
// the portal (content is fixed-positioned at the viewport centre).
|
||||
export const Open = () => (
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete resume</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently deletes “Software Engineer” along with its version history. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
|
||||
<DialogClose render={<Button variant="destructive" />}>Delete resume</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CopyIcon, DownloadSimpleIcon, PencilIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
|
||||
// Anchored menu — rendered open (defaultOpen), positioned below its trigger.
|
||||
// cfg.overrides.DropdownMenu pins cardMode: single + viewport with room below.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 16, paddingBottom: 220 }}>
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>Resume actions</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Software Engineer</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon /> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon /> Duplicate
|
||||
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DownloadSimpleIcon /> Export PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<TrashIcon /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import type * as React from "react";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
|
||||
// FormItem carries its own field context (id + error state) — FormLabel /
|
||||
// FormControl / FormDescription / FormMessage compose under it standalone.
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 340 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={wrap}>
|
||||
<FormItem>
|
||||
<FormLabel>Headline</FormLabel>
|
||||
<FormControl render={<Input placeholder="Senior Software Engineer" />} />
|
||||
<FormDescription>Shown under your name at the top of the resume.</FormDescription>
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithError = () => (
|
||||
<div style={wrap}>
|
||||
<FormItem hasError>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl render={<Input defaultValue="jane@" />} />
|
||||
<FormMessage errors={["Enter a valid email address."]} />
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
|
||||
|
||||
// Anchored preview-card overlay — rendered open (defaultOpen).
|
||||
// cfg.overrides.HoverCard pins cardMode: single + viewport with room below the trigger.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 180 }}>
|
||||
<HoverCard defaultOpen>
|
||||
<HoverCardTrigger render={<Button variant="link" />}>@jane-doe</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontWeight: 600 }}>Jane Doe</span>
|
||||
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer · San Francisco</span>
|
||||
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>3 published resumes · joined 2023</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import type * as React from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="full-name">Full name</Label>
|
||||
<Input id="full-name" defaultValue="Ada Lovelace" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Placeholder = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="headline">Headline</Label>
|
||||
<Input id="headline" placeholder="e.g. Senior Software Engineer" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Email = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" defaultValue="ada@analyticalengine.dev" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Invalid = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input id="website" aria-invalid defaultValue="not-a-valid-url" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" disabled defaultValue="ada.lovelace" />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type * as React from "react";
|
||||
import { CopyIcon, GlobeIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 380 };
|
||||
|
||||
export const Addons = () => (
|
||||
<div style={wrap}>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput placeholder="Search resumes" defaultValue="Product Designer" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<GlobeIcon />
|
||||
<InputGroupText>rxresu.me/u/</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput defaultValue="jordan-rivera" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput readOnly defaultValue="rxr_live_9f3c8a21bd47e50a" />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-sm" aria-label="Copy API key">
|
||||
<CopyIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithTextarea = () => (
|
||||
<div style={wrap}>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
rows={3}
|
||||
defaultValue="Senior product designer focused on design systems, accessibility, and shipping polished interfaces."
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<InputGroupText>240 characters left</InputGroupText>
|
||||
<InputGroupButton style={{ marginLeft: "auto" }}>Generate with AI</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import type * as React from "react";
|
||||
import { Kbd, KbdGroup } from "@reactive-resume/ui/components/kbd";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 20 };
|
||||
const listRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 24,
|
||||
fontSize: 13,
|
||||
color: "var(--foreground)",
|
||||
};
|
||||
const col: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 20, minWidth: 260 };
|
||||
|
||||
export const Keys = () => (
|
||||
<div style={row}>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
<Kbd>⌥</Kbd>
|
||||
<Kbd>Esc</Kbd>
|
||||
<Kbd>Enter</Kbd>
|
||||
<Kbd>Tab</Kbd>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Combinations = () => (
|
||||
<div style={row}>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>S</Kbd>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
<Kbd>P</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ShortcutList = () => (
|
||||
<div style={col}>
|
||||
<div style={listRow}>
|
||||
<span>Command palette</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
<div style={listRow}>
|
||||
<span>Save resume</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>S</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
<div style={listRow}>
|
||||
<span>Undo</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>Z</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type * as React from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 320 };
|
||||
|
||||
export const WithInput = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="company">Company</Label>
|
||||
<Input id="company" defaultValue="Analytical Engine Co." />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Required = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="job-title">
|
||||
Job title <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</Label>
|
||||
<Input id="job-title" placeholder="e.g. Lead Engineer" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithSwitch = () => (
|
||||
<div style={row}>
|
||||
<Switch id="public-resume" defaultChecked />
|
||||
<Label htmlFor="public-resume">Public resume</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="locked-field" data-disabled="true" style={{ opacity: 0.5 }}>
|
||||
Locked field
|
||||
</Label>
|
||||
<Input id="locked-field" disabled defaultValue="Read only" />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type * as React from "react";
|
||||
import { CheckCircleIcon, SparkleIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
|
||||
|
||||
export const Statuses = () => (
|
||||
<div style={wrap}>
|
||||
<Marker style={{ width: "fit-content", borderRadius: 8, padding: "12px 16px", background: "var(--muted)" }}>
|
||||
<MarkerIcon>
|
||||
<SparkleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Tailoring your resume…</MarkerContent>
|
||||
</Marker>
|
||||
<Marker style={{ width: "fit-content" }}>
|
||||
<MarkerIcon>
|
||||
<CheckCircleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Applied 4 edits to your resume</MarkerContent>
|
||||
</Marker>
|
||||
<Marker style={{ width: "fit-content" }}>
|
||||
<MarkerIcon>
|
||||
<WarningCircleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Couldn't reach the AI provider</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Dividers = () => (
|
||||
<div style={wrap}>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="border">
|
||||
<MarkerContent>Conversation history</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import type * as React from "react";
|
||||
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
|
||||
import {
|
||||
Message,
|
||||
MessageAvatar,
|
||||
MessageContent,
|
||||
MessageFooter,
|
||||
MessageGroup,
|
||||
MessageHeader,
|
||||
} from "@reactive-resume/ui/components/message";
|
||||
|
||||
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
|
||||
|
||||
export const Conversation = () => (
|
||||
<div style={{ display: "flex", padding: 16, width: 460 }}>
|
||||
<MessageGroup style={{ width: "100%" }}>
|
||||
<Message align="end">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<UserIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Tailor my resume for a product manager role.</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message align="start">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<SparkleIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble variant="muted" align="start">
|
||||
<BubbleContent>
|
||||
Done — I emphasized roadmap ownership and stakeholder communication in your summary.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</MessageGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithMeta = () => (
|
||||
<div style={{ display: "flex", padding: 16, width: 460 }}>
|
||||
<Message align="start">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<SparkleIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<MessageHeader>Reactive AI</MessageHeader>
|
||||
<Bubble variant="tinted" align="start">
|
||||
<BubbleContent>I found 4 weak bullets and rewrote them with stronger verbs and metrics.</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter>Just now · applied 4 edits</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type * as React from "react";
|
||||
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
|
||||
import { Message, MessageAvatar, MessageContent } from "@reactive-resume/ui/components/message";
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@reactive-resume/ui/components/message-scroller";
|
||||
|
||||
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
|
||||
|
||||
const turns = [
|
||||
{ role: "user", text: "Can you review my resume for a senior engineering role?" },
|
||||
{ role: "assistant", text: "Sure — I'll focus on scope, impact, and leadership signals. Reading it now." },
|
||||
{ role: "user", text: "Great, keep the tone concise." },
|
||||
{
|
||||
role: "assistant",
|
||||
text: "I rewrote your summary and tightened three experience bullets with measurable outcomes.",
|
||||
},
|
||||
{ role: "user", text: "Perfect, publish the draft." },
|
||||
{ role: "assistant", text: "Draft saved and published to your public resume. Anything else you'd like to refine?" },
|
||||
];
|
||||
|
||||
export const Thread = () => (
|
||||
<div style={{ height: 340, width: 460, padding: 12 }}>
|
||||
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent style={{ padding: 12 }}>
|
||||
{turns.map((turn, index) => (
|
||||
<MessageScrollerItem key={turn.text} messageId={`turn-${index}`}>
|
||||
<Message align={turn.role === "user" ? "end" : "start"}>
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>{turn.role === "user" ? <UserIcon /> : <SparkleIcon />}</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble
|
||||
variant={turn.role === "user" ? "default" : "muted"}
|
||||
align={turn.role === "user" ? "end" : "start"}
|
||||
>
|
||||
<BubbleContent>{turn.text}</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@reactive-resume/ui/components/popover";
|
||||
|
||||
// Anchored overlay — rendered open (defaultOpen), positioned below its trigger.
|
||||
// cfg.overrides.Popover pins cardMode: single + viewport with room for the popup.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 220 }}>
|
||||
<Popover defaultOpen>
|
||||
<PopoverTrigger render={<Button variant="outline" />}>Share resume</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Public link</PopoverTitle>
|
||||
<PopoverDescription>Anyone with this link can view your published resume.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Copy link
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type * as React from "react";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
|
||||
const panelStyle: React.CSSProperties = { height: "100%", padding: 16, fontSize: 14, lineHeight: 1.6 };
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--muted-foreground)",
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
export const BuilderLayout = () => (
|
||||
<div style={{ height: 240, width: 460, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
|
||||
<ResizableGroup orientation="horizontal">
|
||||
<ResizablePanel defaultSize={40}>
|
||||
<div style={panelStyle}>
|
||||
<div style={label}>Editor</div>
|
||||
<div>Basics</div>
|
||||
<div>Work Experience</div>
|
||||
<div>Education</div>
|
||||
<div>Skills</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel defaultSize={60}>
|
||||
<div style={{ ...panelStyle, background: "var(--muted)" }}>
|
||||
<div style={label}>Live Preview</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16 }}>Jordan Rivera</div>
|
||||
<div style={{ color: "var(--muted-foreground)" }}>Senior Product Designer</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const VerticalSplit = () => (
|
||||
<div style={{ height: 240, width: 300, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
|
||||
<ResizableGroup orientation="vertical">
|
||||
<ResizablePanel defaultSize={50}>
|
||||
<div style={panelStyle}>
|
||||
<div style={label}>Summary</div>
|
||||
<div>Eight years building design systems and shipping delightful product experiences.</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel defaultSize={50}>
|
||||
<div style={{ ...panelStyle, background: "var(--muted)" }}>
|
||||
<div style={label}>Contact</div>
|
||||
<div>jordan.rivera@email.com</div>
|
||||
<div>San Francisco, CA</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type * as React from "react";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
|
||||
const templates = [
|
||||
{ name: "Azurill", tag: "Minimal" },
|
||||
{ name: "Bronzor", tag: "Classic" },
|
||||
{ name: "Chikorita", tag: "Modern" },
|
||||
{ name: "Ditto", tag: "Compact" },
|
||||
{ name: "Gengar", tag: "Bold" },
|
||||
{ name: "Glalie", tag: "Elegant" },
|
||||
{ name: "Kakuna", tag: "Timeless" },
|
||||
{ name: "Leafish", tag: "Creative" },
|
||||
{ name: "Nosepass", tag: "Formal" },
|
||||
{ name: "Onyx", tag: "Technical" },
|
||||
{ name: "Pikachu", tag: "Friendly" },
|
||||
{ name: "Rhyhorn", tag: "Corporate" },
|
||||
];
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
export const TemplateList = () => (
|
||||
<ScrollArea style={{ height: 240, width: 320, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||
<div style={{ padding: 4 }}>
|
||||
{templates.map((template) => (
|
||||
<div key={template.name} style={rowStyle}>
|
||||
<span style={{ fontWeight: 500 }}>{template.name}</span>
|
||||
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>{template.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type * as React from "react";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
|
||||
const block: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
maxWidth: 360,
|
||||
fontSize: 13,
|
||||
color: "var(--foreground)",
|
||||
};
|
||||
const inline: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
export const Horizontal = () => (
|
||||
<div style={block}>
|
||||
<div>
|
||||
<strong style={{ display: "block", fontSize: 14 }}>Amruth Pillai</strong>
|
||||
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<span style={{ color: "var(--muted-foreground)" }}>
|
||||
Building resume tooling at Reactive Resume. Open-source enthusiast.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Vertical = () => (
|
||||
<div style={inline}>
|
||||
<span>Profile</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Experience</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Education</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Skills</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
|
||||
// Side drawer — rendered open (defaultOpen), anchored to the right edge.
|
||||
// cfg.overrides.Sheet pins cardMode: single + viewport.
|
||||
export const Open = () => (
|
||||
<Sheet defaultOpen>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Resume settings</SheetTitle>
|
||||
<SheetDescription>Control how “Software Engineer” appears when shared publicly.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "0 16px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Label>Public slug</Label>
|
||||
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>rxresume.me/jane-doe</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Label>Visibility</Label>
|
||||
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>Anyone with the link can view</span>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
|
||||
<SheetClose render={<Button />}>Save changes</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FileTextIcon, GearIcon, HouseIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from "@reactive-resume/ui/components/sidebar";
|
||||
|
||||
// SidebarProvider supplies context + --sidebar-width. `collapsible="none"`
|
||||
// renders the sidebar inline (the default offcanvas variant is fixed-positioned
|
||||
// and would escape the card).
|
||||
export const Navigation = () => (
|
||||
<SidebarProvider>
|
||||
<div
|
||||
style={{ height: 400, display: "flex", border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}
|
||||
>
|
||||
<Sidebar collapsible="none">
|
||||
<SidebarHeader>
|
||||
<div style={{ padding: 8, fontWeight: 600, fontSize: 14 }}>Reactive Resume</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive>
|
||||
<HouseIcon /> Dashboard
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<FileTextIcon /> Resumes
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<PlusIcon /> New resume
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<GearIcon /> Settings
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import type * as React from "react";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
|
||||
const pad: React.CSSProperties = { padding: 20 };
|
||||
|
||||
export const TextLines = () => (
|
||||
<div style={{ ...pad, display: "flex", flexDirection: "column", gap: 10, width: 320 }}>
|
||||
<Skeleton style={{ height: 12, width: "70%" }} />
|
||||
<Skeleton style={{ height: 12, width: "100%" }} />
|
||||
<Skeleton style={{ height: 12, width: "90%" }} />
|
||||
<Skeleton style={{ height: 12, width: "40%" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProfileHeader = () => (
|
||||
<div style={{ ...pad, display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Skeleton style={{ height: 48, width: 48, borderRadius: "9999px" }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Skeleton style={{ height: 14, width: 160 }} />
|
||||
<Skeleton style={{ height: 12, width: 100 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ResumeCard = () => (
|
||||
<div
|
||||
style={{
|
||||
...pad,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
width: 220,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Skeleton style={{ height: 140, width: "100%" }} />
|
||||
<Skeleton style={{ height: 14, width: "60%" }} />
|
||||
<Skeleton style={{ height: 12, width: "40%" }} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 16, width: 320 };
|
||||
|
||||
export const Single = () => (
|
||||
<div style={field}>
|
||||
<Label>Skill level</Label>
|
||||
<Slider defaultValue={[4]} min={0} max={5} step={1} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Range = () => (
|
||||
<div style={field}>
|
||||
<Label>Experience (years)</Label>
|
||||
<Slider defaultValue={[2, 8]} min={0} max={15} step={1} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FontScale = () => (
|
||||
<div style={field}>
|
||||
<Label>Font size</Label>
|
||||
<Slider defaultValue={[62]} min={0} max={100} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label style={{ opacity: 0.5 }}>Line height (locked)</Label>
|
||||
<Slider defaultValue={[50]} min={0} max={100} disabled />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import type * as React from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 20, padding: 24 };
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Spinner style={{ width: 16, height: 16 }} />
|
||||
<Spinner style={{ width: 24, height: 24 }} />
|
||||
<Spinner style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Colors = () => (
|
||||
<div style={row}>
|
||||
<Spinner style={{ width: 28, height: 28, color: "var(--primary)" }} />
|
||||
<Spinner style={{ width: 28, height: 28, color: "var(--muted-foreground)" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LoadingRow = () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
<Spinner style={{ width: 18, height: 18 }} />
|
||||
<span>Generating your PDF…</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 300 };
|
||||
const stack: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 300 };
|
||||
|
||||
export const On = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-public" defaultChecked />
|
||||
<Label htmlFor="sw-public">Public resume</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Off = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-template" />
|
||||
<Label htmlFor="sw-template">Show icons in template</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Small = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-page-numbers" size="sm" defaultChecked />
|
||||
<Label htmlFor="sw-page-numbers">Show page numbers</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={stack}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Switch id="sw-ai" disabled defaultChecked />
|
||||
<Label htmlFor="sw-ai">AI suggestions</Label>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Switch id="sw-index" disabled />
|
||||
<Label htmlFor="sw-index">Index on search engines</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type * as React from "react";
|
||||
import { BriefcaseIcon, GraduationCapIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
|
||||
const wrap: React.CSSProperties = { width: 460, padding: 16 };
|
||||
const panel: React.CSSProperties = { padding: "12px 4px", lineHeight: 1.6 };
|
||||
|
||||
export const ResumeSections = () => (
|
||||
<div style={wrap}>
|
||||
<Tabs defaultValue="experience">
|
||||
<TabsList>
|
||||
<TabsTrigger value="experience">
|
||||
<BriefcaseIcon /> Experience
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="education">
|
||||
<GraduationCapIcon /> Education
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills">
|
||||
<SparkleIcon /> Skills
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="experience" style={panel}>
|
||||
<strong>Staff Engineer · Vercel</strong>
|
||||
<div>Owned the edge runtime rollout serving 2B requests per day.</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="education" style={panel}>
|
||||
<strong>M.S. Computer Science · Carnegie Mellon</strong>
|
||||
<div>Focus on distributed systems and human-computer interaction.</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="skills" style={panel}>
|
||||
<strong>Core stack</strong>
|
||||
<div>TypeScript, React, Go, PostgreSQL, and Kubernetes.</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LineVariant = () => (
|
||||
<div style={wrap}>
|
||||
<Tabs defaultValue="preview">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="share">Share</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="preview" style={panel}>
|
||||
Your resume renders live as you edit each section.
|
||||
</TabsContent>
|
||||
<TabsContent value="share" style={panel}>
|
||||
Publish a public link at reactive-resume.app/u/your-name.
|
||||
</TabsContent>
|
||||
<TabsContent value="export" style={panel}>
|
||||
Download a print-ready PDF or DOCX in one click.
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 360 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="summary">Summary</Label>
|
||||
<Textarea id="summary" placeholder="Write a short professional summary…" rows={4} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Filled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="about">About</Label>
|
||||
<Textarea
|
||||
id="about"
|
||||
rows={4}
|
||||
defaultValue="Mathematician and writer, known for early work on Charles Babbage's Analytical Engine and the first published algorithm intended for a machine."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Invalid = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="bio">Biography</Label>
|
||||
<Textarea id="bio" aria-invalid rows={3} defaultValue="Too short." />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="notes">Internal notes</Label>
|
||||
<Textarea id="notes" disabled rows={3} defaultValue="Notes are locked while this resume is published." />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@reactive-resume/ui/components/sonner";
|
||||
|
||||
// Toaster is the toast host. Fire a persistent toast on mount so the card
|
||||
// shows a real notification instead of an empty portal.
|
||||
export const Notification = () => {
|
||||
useEffect(() => {
|
||||
toast.success("Resume published", {
|
||||
description: "“Software Engineer” is now live at rxresume.me/jane-doe.",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ minHeight: 140 }}>
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
TextBolderIcon,
|
||||
TextItalicIcon,
|
||||
TextUnderlineIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Toggle } from "@reactive-resume/ui/components/toggle";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 16 };
|
||||
const group: React.CSSProperties = { display: "flex", alignItems: "center", gap: 2, padding: 16 };
|
||||
|
||||
export const States = () => (
|
||||
<div style={row}>
|
||||
<Toggle aria-label="Bold" defaultPressed>
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="Italic">
|
||||
<TextItalicIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="Underline" disabled>
|
||||
<TextUnderlineIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Outline = () => (
|
||||
<div style={row}>
|
||||
<Toggle variant="outline" defaultPressed>
|
||||
<TextBolderIcon /> Bold
|
||||
</Toggle>
|
||||
<Toggle variant="outline">
|
||||
<TextItalicIcon /> Italic
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Toggle size="sm" aria-label="Bold small">
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle size="default" aria-label="Bold default" defaultPressed>
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle size="lg" aria-label="Bold large">
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AlignmentGroup = () => (
|
||||
<div style={group}>
|
||||
<Toggle variant="outline" aria-label="Align left" defaultPressed>
|
||||
<TextAlignLeftIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline" aria-label="Align center">
|
||||
<TextAlignCenterIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline" aria-label="Align right">
|
||||
<TextAlignRightIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
|
||||
// Anchored overlay — needs TooltipProvider; rendered open (defaultOpen).
|
||||
// cfg.overrides.Tooltip pins cardMode: single + viewport with room above the trigger.
|
||||
export const Open = () => (
|
||||
<TooltipProvider>
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 120, paddingBottom: 24 }}>
|
||||
<Tooltip defaultOpen>
|
||||
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="About visibility" />}>
|
||||
<InfoIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Only you can see private resumes</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
/* design-sync Tailwind entry — compiles the UI package's globals.css to static CSS
|
||||
for preview rendering, and also scans authored previews for used utilities. */
|
||||
@import "../packages/ui/src/styles/globals.css";
|
||||
@source "./previews/*.tsx";
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node:24
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "Reactive Resume",
|
||||
"service": "reactive_resume",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"workspaceFolder": "/workspace",
|
||||
|
||||
"forwardPorts": [3000, 4000, 5432, 8333],
|
||||
"portsAttributes": {
|
||||
"3000": { "label": "Reactive Resume", "onAutoForward": "openBrowser" },
|
||||
"4000": { "label": "Browserless (Printer)" },
|
||||
"5432": { "label": "PostgreSQL" },
|
||||
"8333": { "label": "SeaweedFS (S3)" }
|
||||
},
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"oxc.oxc-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"lokalise.i18n-ally",
|
||||
"typescriptteam.native-preview"
|
||||
],
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.oxc": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "corepack enable && pnpm install"
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
services:
|
||||
reactive_resume:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
browserless:
|
||||
condition: service_started
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
seaweedfs_create_bucket:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
TZ: Etc/UTC
|
||||
APP_URL: http://localhost:3000
|
||||
PRINTER_APP_URL: http://reactive_resume:3000
|
||||
PRINTER_ENDPOINT: ws://browserless:3000?token=1234567890
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
|
||||
AUTH_SECRET: change-me-to-a-secure-secret-key-in-production
|
||||
S3_ACCESS_KEY_ID: seaweedfs
|
||||
S3_SECRET_ACCESS_KEY: seaweedfs
|
||||
S3_REGION: us-east-1
|
||||
S3_ENDPOINT: http://seaweedfs:8333
|
||||
S3_BUCKET: reactive-resume
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
QUEUED: "10"
|
||||
HEALTH: "true"
|
||||
CONCURRENT: "5"
|
||||
TOKEN: "1234567890"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
restart: unless-stopped
|
||||
command: server -s3 -filer -dir=/data -ip=0.0.0.0
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: seaweedfs
|
||||
AWS_SECRET_ACCESS_KEY: seaweedfs
|
||||
volumes:
|
||||
- seaweedfs_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8888"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
seaweedfs_create_bucket:
|
||||
image: quay.io/minio/mc:latest
|
||||
restart: on-failure
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until mc alias set seaweedfs http://seaweedfs:8333 seaweedfs seaweedfs; do
|
||||
echo 'Waiting for SeaweedFS...';
|
||||
sleep 2;
|
||||
done;
|
||||
mc mb seaweedfs/reactive-resume --ignore-existing;
|
||||
"
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
seaweedfs_data:
|
||||
+24
-7
@@ -1,15 +1,32 @@
|
||||
dist
|
||||
docs
|
||||
# VCS and editor state
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
.DS_Store
|
||||
.vite-hooks
|
||||
|
||||
# Local configuration and runtime state
|
||||
.env*
|
||||
/data
|
||||
|
||||
# Dependency and package-manager caches
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build, test, and framework output
|
||||
dist
|
||||
**/dist
|
||||
.nitro
|
||||
.cursor
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
coverage
|
||||
.DS_Store
|
||||
.tanstack
|
||||
node_modules
|
||||
.turbo
|
||||
**/.turbo
|
||||
coverage
|
||||
reports
|
||||
|
||||
# Generated service-worker assets are rebuilt during the web build.
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
public/workbox-*.js
|
||||
|
||||
+62
-31
@@ -1,93 +1,124 @@
|
||||
# --- Server ---
|
||||
TZ="Etc/UTC"
|
||||
# --- Application ---
|
||||
# Public port used by the production server and the Vite web server in local development.
|
||||
PORT="3000"
|
||||
|
||||
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
||||
SERVER_PORT="3001"
|
||||
|
||||
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
||||
# OpenGraph metadata, and absolute upload URLs.
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Optional, uses APP_URL by default
|
||||
# PLEASE READ: This should be set to an internal URL (like http://host.docker.internal:3000 or http://{docker_service}:3000)
|
||||
# to let the browser navigate to a non-public instance of Reactive Resume.
|
||||
# This is required when the printer service is running inside Docker, and cannot reach the app via the APP URL,
|
||||
# which is usually when the APP_URL is localhost or a local network IP/hostname.
|
||||
PRINTER_APP_URL="http://host.docker.internal:3000"
|
||||
|
||||
# --- Printer ---
|
||||
PRINTER_ENDPOINT="ws://localhost:4000?token=change-me"
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
|
||||
|
||||
# Better Auth Dashboard (optional)
|
||||
# Enables the Better Auth Dashboard plugin when set, you probably don't need this.
|
||||
BETTER_AUTH_API_KEY=""
|
||||
|
||||
# Social Auth (Google, optional)
|
||||
# Set both values to enable Google sign-in.
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Social Auth (GitHub, optional)
|
||||
# Set both values to enable GitHub sign-in.
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Social Auth (LinkedIn, optional)
|
||||
# Set both values to enable LinkedIn sign-in.
|
||||
LINKEDIN_CLIENT_ID=""
|
||||
LINKEDIN_CLIENT_SECRET=""
|
||||
|
||||
# Custom OAuth Provider (optional)
|
||||
# Set OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET plus either OAUTH_DISCOVERY_URL or
|
||||
# the three manual endpoint URLs below.
|
||||
OAUTH_PROVIDER_NAME=""
|
||||
OAUTH_CLIENT_ID=""
|
||||
OAUTH_CLIENT_SECRET=""
|
||||
OAUTH_DISCOVERY_URL=""
|
||||
OAUTH_AUTHORIZATION_URL=""
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
|
||||
OAUTH_TOKEN_URL=""
|
||||
OAUTH_USER_INFO_URL=""
|
||||
|
||||
# AI provider base URL allowlist (optional, comma-separated hosts/origins)
|
||||
# Example: api.openai.com,https://gateway.ai.vercel.com
|
||||
AI_ALLOWED_BASE_URLS=""
|
||||
# Space-separated scopes requested from the custom OAuth provider.
|
||||
OAUTH_SCOPES="openid profile email"
|
||||
|
||||
# --- Email (optional) ---
|
||||
# If all keys are disabled, the app logs the email to be sent to the console instead.
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="1025"
|
||||
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
||||
# email to the console instead.
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# --- Storage (optional) ---
|
||||
# If all keys are disabled, the app uses local filesystem (/data) to store uploads instead.
|
||||
# If all S3 keys are disabled, the app uses local filesystem storage instead.
|
||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
|
||||
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
|
||||
# LOCAL_STORAGE_PATH="/app/data"
|
||||
|
||||
# Seaweedfs
|
||||
S3_ACCESS_KEY_ID="seaweedfs"
|
||||
S3_SECRET_ACCESS_KEY="seaweedfs"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ENDPOINT="http://localhost:8333"
|
||||
S3_ENDPOINT="http://seaweedfs:8333"
|
||||
S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- Feature Flags ---
|
||||
# This flag bypasses the check that the endpoint to fetch data for the printing of PDFs `getByIdForPrinter`, is only accessible from the server.
|
||||
# Useful for when you want to debug the /printer/{resumeId} route to quickly take a peek at the page that is sent to the printer.
|
||||
FLAG_DEBUG_PRINTER="false"
|
||||
# --- AI Agent Workspace (optional) ---
|
||||
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||
REDIS_URL="redis://redis:6379"
|
||||
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||
|
||||
# --- Feature Flags ---
|
||||
# This flag disables new signups, both on the web app and the server.
|
||||
FLAG_DISABLE_SIGNUPS="false"
|
||||
|
||||
# This flag disables email/password login. Disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true.
|
||||
# This flag disables email/password login. Disables email verification, forgot password, and reset password flows.
|
||||
# Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true.
|
||||
FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
|
||||
# This flag disables the image processing.
|
||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||
|
||||
# This flag disables API rate limiting for authentication endpoints.
|
||||
# Rate limiting is enabled by default in production to prevent abuse.
|
||||
FLAG_DISABLE_API_RATE_LIMIT="false"
|
||||
|
||||
# This flag shows sponsor placements on the public landing page.
|
||||
FLAG_SHOW_SPONSORS="false"
|
||||
|
||||
# Allows dynamic OAuth client registration to use any parseable redirect URI,
|
||||
# including custom schemes, private hosts, and non-loopback http:// URLs.
|
||||
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
|
||||
# or token exfiltration. Only enable this on a trusted, self-hosted instance.
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
|
||||
|
||||
# Allows AI providers to be configured with any base URL, including http:// and
|
||||
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
|
||||
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
|
||||
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
|
||||
|
||||
# --- Others ---
|
||||
# Google Cloud API Key (optional)
|
||||
# This is not used within Reactive Resume, but in src/scripts/fonts/generate.ts to generate a list of fonts served by Google Fonts.
|
||||
# Note: Make sure "Google Fonts Developer API" is unrestricted.
|
||||
# For font-list generation tooling.
|
||||
# Requires "Google Fonts Developer API" to be enabled.
|
||||
GOOGLE_CLOUD_API_KEY=""
|
||||
|
||||
# --- Crowdin ---
|
||||
# Crowdin (optional)
|
||||
# For translation tooling.
|
||||
CROWDIN_PROJECT_ID=""
|
||||
CROWDIN_API_TOKEN=""
|
||||
CROWDIN_API_TOKEN=""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
locales/*.po linguist-generated=true
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
|
||||
"version": "v1.1.0",
|
||||
"entity": {
|
||||
"type": "individual",
|
||||
"role": "maintainer",
|
||||
"name": "Amruth Pillai",
|
||||
"email": "im.amruth@gmail.com",
|
||||
"description": "Software Engineer",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me/funding.json"
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"guid": "reactive-resume",
|
||||
"name": "Reactive Resume",
|
||||
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/amruthpillai/reactive-resume",
|
||||
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
|
||||
},
|
||||
"licenses": ["spdx:MIT"],
|
||||
"tags": ["data", "design", "productivity", "resume-builder"]
|
||||
}
|
||||
],
|
||||
"funding": {
|
||||
"plans": [
|
||||
{
|
||||
"guid": "sponsor",
|
||||
"status": "active",
|
||||
"name": "Sponsor",
|
||||
"description": "Support the project on a recurring basis by becoming a sponsor.",
|
||||
"amount": 10,
|
||||
"currency": "EUR",
|
||||
"frequency": "monthly",
|
||||
"channels": ["github", "open-collective"]
|
||||
},
|
||||
{
|
||||
"guid": "donation",
|
||||
"status": "active",
|
||||
"name": "Donation",
|
||||
"description": "Show your support for the project by making a one-time donation.",
|
||||
"amount": 0,
|
||||
"currency": "EUR",
|
||||
"frequency": "one-time",
|
||||
"channels": ["github", "open-collective"]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"guid": "github",
|
||||
"type": "payment-provider",
|
||||
"description": "GitHub Sponsors",
|
||||
"address": "https://github.com/sponsors/AmruthPillai"
|
||||
},
|
||||
{
|
||||
"guid": "open-collective",
|
||||
"type": "payment-provider",
|
||||
"description": "Open Collective",
|
||||
"address": "https://opencollective.com/reactive-resume"
|
||||
}
|
||||
]
|
||||
}
|
||||
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
|
||||
"version": "v1.1.0",
|
||||
"entity": {
|
||||
"type": "individual",
|
||||
"role": "maintainer",
|
||||
"name": "Amruth Pillai",
|
||||
"email": "hello@amruthpillai.com",
|
||||
"description": "Software Engineer",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me/funding.json"
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"guid": "reactive-resume",
|
||||
"name": "Reactive Resume",
|
||||
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/amruthpillai/reactive-resume",
|
||||
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
|
||||
},
|
||||
"licenses": ["spdx:MIT"],
|
||||
"tags": ["data", "design", "productivity", "resume-builder"]
|
||||
}
|
||||
],
|
||||
"funding": {
|
||||
"plans": [
|
||||
{
|
||||
"guid": "sponsor",
|
||||
"status": "active",
|
||||
"name": "Sponsor",
|
||||
"description": "Support the project on a recurring basis by becoming a sponsor.",
|
||||
"amount": 10,
|
||||
"currency": "EUR",
|
||||
"frequency": "monthly",
|
||||
"channels": ["github", "open-collective"]
|
||||
},
|
||||
{
|
||||
"guid": "donation",
|
||||
"status": "active",
|
||||
"name": "Donation",
|
||||
"description": "Show your support for the project by making a one-time donation.",
|
||||
"amount": 0,
|
||||
"currency": "EUR",
|
||||
"frequency": "one-time",
|
||||
"channels": ["github", "open-collective"]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"guid": "github",
|
||||
"type": "payment-provider",
|
||||
"description": "GitHub Sponsors",
|
||||
"address": "https://github.com/sponsors/AmruthPillai"
|
||||
},
|
||||
{
|
||||
"guid": "open-collective",
|
||||
"type": "payment-provider",
|
||||
"description": "Open Collective",
|
||||
"address": "https://opencollective.com/reactive-resume/donate"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ body:
|
||||
- Onyx
|
||||
- Pikachu
|
||||
- Rhyhorn
|
||||
- Scizor
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -19,20 +19,30 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@v1
|
||||
- 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
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
node-version: "lts/*"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: vp install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Tests
|
||||
run: vp test
|
||||
- name: Remove Unused Dependencies
|
||||
run: pnpm knip --fix
|
||||
|
||||
- name: Run Lint and Format
|
||||
run: vp check --fix
|
||||
- name: Lint and Format
|
||||
run: pnpm check
|
||||
|
||||
- name: Autofix
|
||||
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Build Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,17 +17,34 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
mode:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
nightly: ${{ steps.mode.outputs.nightly }}
|
||||
release: ${{ steps.mode.outputs.release }}
|
||||
matrix: ${{ steps.mode.outputs.matrix }}
|
||||
|
||||
steps:
|
||||
- name: Determine publishing mode
|
||||
id: mode
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
echo "nightly=true" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: mode
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
@@ -97,7 +119,9 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: build
|
||||
needs:
|
||||
- mode
|
||||
- build
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -160,16 +184,25 @@ jobs:
|
||||
docker.io/${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest
|
||||
type=raw,value=v${{ steps.version.outputs.version }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}
|
||||
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
type=raw,value=v${{ steps.version.outputs.version }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
id: manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
|
||||
FINAL_TAG="nightly"
|
||||
else
|
||||
FINAL_TAG="v${{ steps.version.outputs.version }}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
--annotation "index:org.opencontainers.image.licenses=MIT" \
|
||||
@@ -184,8 +217,9 @@ jobs:
|
||||
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
# Get the digest of the multi-arch manifest
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -202,10 +236,11 @@ jobs:
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
|
||||
- name: Redeploy Stack
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
@@ -214,3 +249,24 @@ jobs:
|
||||
script: |
|
||||
cd docker
|
||||
./manage_stack.sh up reactive_resume
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
env:
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}')
|
||||
|
||||
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cloudflare cache purged successfully."
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
APP_URL: http://localhost:3000
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
||||
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Test Secrets
|
||||
run: |
|
||||
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Storage
|
||||
run: mkdir -p "$LOCAL_STORAGE_PATH"
|
||||
|
||||
- name: Run Database Migrations
|
||||
run: pnpm db:migrate
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: pnpm test:e2e:ci
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
+68
-19
@@ -1,21 +1,70 @@
|
||||
dist
|
||||
.env*
|
||||
/data
|
||||
.nitro
|
||||
.agents
|
||||
.output
|
||||
.vercel
|
||||
.cursor
|
||||
.claude
|
||||
TODO.md
|
||||
coverage
|
||||
.netlify
|
||||
.DS_Store
|
||||
.tanstack
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build Outputs
|
||||
dist
|
||||
.vercel
|
||||
.wrangler
|
||||
|
||||
# Environment Variables
|
||||
.env*
|
||||
!.env.example
|
||||
public/sw.js
|
||||
public/sw.js.map
|
||||
scripts/**/*.json
|
||||
public/workbox-*.js
|
||||
public/workbox-*.js.map
|
||||
|
||||
# IDEs and Editors
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Log Files
|
||||
logs
|
||||
*.log*
|
||||
|
||||
# Turborepo
|
||||
.turbo
|
||||
|
||||
# TanStack Start
|
||||
.tanstack
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Cache
|
||||
tmp
|
||||
temp
|
||||
.cache
|
||||
|
||||
# AI
|
||||
.codex
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks/
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
packages/ui/.ds-compiled.css
|
||||
packages/ui/.ds-tw-raw.css
|
||||
packages/ui/dist/
|
||||
i18n.cache
|
||||
|
||||
+11
-11
@@ -1,17 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
const nextPackages = ["@monaco-editor/react"];
|
||||
|
||||
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
|
||||
const betaPackages = ["drizzle-zod"];
|
||||
const rcPackages = ["drizzle-orm", "drizzle-kit"];
|
||||
|
||||
/** @type {import('npm-check-updates').RunOptions} */
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
install: "always",
|
||||
packageManager: "pnpm",
|
||||
target: (packageName) => {
|
||||
if (nextPackages.includes(packageName)) return "@next";
|
||||
if (betaPackages.includes(packageName)) return "@beta";
|
||||
return "latest";
|
||||
},
|
||||
upgrade: true,
|
||||
workspaces: true,
|
||||
install: "always",
|
||||
packageManager: "pnpm",
|
||||
target: (packageName) => {
|
||||
if (betaPackages.includes(packageName)) return "@beta";
|
||||
if (rcPackages.includes(packageName)) return "@rc";
|
||||
return "latest";
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
vp staged
|
||||
Vendored
+1
-7
@@ -1,9 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"oxc.oxc-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"lokalise.i18n-ally",
|
||||
"typescriptteam.native-preview",
|
||||
"VoidZero.vite-plus-extension-pack"
|
||||
]
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
||||
}
|
||||
|
||||
Vendored
+32
-39
@@ -1,41 +1,34 @@
|
||||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll.oxc": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true,
|
||||
"locales/**.po": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"i18n-ally.enabledParsers": ["po"],
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"oxc.enable.oxfmt": true,
|
||||
"oxc.enable.oxlint": true,
|
||||
"oxc.configPath": "./vite.config.ts",
|
||||
"oxc.fmt.configPath": "./vite.config.ts",
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true,
|
||||
"locales/**.po": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"tailwindCSS.classFunctions": ["cn", "cva"],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["class:\\s*?[\"'`]([^\"'`]*).*?,"],
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true
|
||||
"biome.enabled": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"files.readonlyInclude": {
|
||||
"**/locales/**.po": true,
|
||||
"**/routeTree.gen.ts": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/locales/**.po": true,
|
||||
"**/routeTree.gen.ts": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/locales/**.po": true,
|
||||
"**/routeTree.gen.ts": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
"tailwindCSS.classFunctions": ["cn", "cva"],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["class:\\s*?[\"'`]([^\"'`]*).*?,"],
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +1,157 @@
|
||||
## Overview
|
||||
# AGENTS.md
|
||||
|
||||
Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) (React, Vite, Nitro). It serves both frontend and API on port 3000.
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
This project uses [Vite+](https://vite.dev/blog/announcing-viteplus), a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. All modules should be imported from the `vite-plus` dependency (e.g., `import { defineConfig } from 'vite-plus'` or `import { expect, test, vi } from 'vite-plus/test'`).
|
||||
### Overview
|
||||
|
||||
## Key Libraries
|
||||
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000, with `apps/server` mounting the API/auth/MCP/static routes and serving the built web app.
|
||||
|
||||
| Area | Library | Docs |
|
||||
| -------------------- | ------------------------------------------------------------------------ | ---------------------------------- |
|
||||
| Frontend framework | React | https://react.dev |
|
||||
| Full-stack framework | TanStack Start | https://tanstack.com/start/latest |
|
||||
| Router | TanStack React Router | https://tanstack.com/router/latest |
|
||||
| Server state | TanStack React Query | https://tanstack.com/query/latest |
|
||||
| Client state | Zustand (+ Zundo for undo/redo, Immer for immutable updates) | https://zustand.docs.pmnd.rs |
|
||||
| Type-safe API | oRPC | https://orpc.unnoq.com |
|
||||
| Database ORM | Drizzle ORM (PostgreSQL) | https://orm.drizzle.team |
|
||||
| Authentication | Better Auth (+ Drizzle adapter, OAuth provider, API keys, 2FA, Passkeys) | https://www.better-auth.com |
|
||||
| Styling | Tailwind CSS | https://tailwindcss.com |
|
||||
| UI Components | shadcn/ui (built on Base UI) | https://ui.shadcn.com |
|
||||
| Icons | Phosphor Icons | https://phosphoricons.com |
|
||||
| Forms | React Hook Form (+ Zod resolvers) | https://react-hook-form.com |
|
||||
| Rich text editor | Tiptap | https://tiptap.dev |
|
||||
| Validation | Zod | https://zod.dev |
|
||||
| AI | Vercel AI SDK (OpenAI, Anthropic, Google, Ollama providers) | https://ai-sdk.dev |
|
||||
| MCP | Model Context Protocol SDK | https://modelcontextprotocol.io |
|
||||
| i18n | Lingui | https://lingui.dev |
|
||||
| Animations | Motion (Framer Motion) | https://motion.dev |
|
||||
| PDF export | Puppeteer Core (via Browserless) | https://pptr.dev |
|
||||
| Drag and drop | dnd-kit | https://dndkit.com |
|
||||
| Server engine | Nitro | https://nitro.build |
|
||||
| PWA | Vite PWA Plugin | https://vite-pwa-org.netlify.app |
|
||||
| Unused deps | Knip | https://knip.dev |
|
||||
Internal packages are source-consumed through `package.json` export maps that point at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
||||
|
||||
## Project Structure
|
||||
### Prerequisites
|
||||
|
||||
```
|
||||
src/
|
||||
components/ UI, resume, layout, animation, theme, locale components
|
||||
routes/ File-based routing (TanStack React Router)
|
||||
integrations/ Feature modules (auth, drizzle, orpc, ai, email, jobs, mcp, storage)
|
||||
schema/ Zod schemas for resume data validation
|
||||
utils/ Utility functions (locale, theme, env, resume processing)
|
||||
dialogs/ Modal/dialog components
|
||||
hooks/ Custom React hooks
|
||||
styles/ CSS and Tailwind configuration
|
||||
stores/ Zustand stores (resume, AI, dialog, command palette)
|
||||
migrations/ Drizzle database migrations
|
||||
locales/ Lingui i18n message catalogs (47+ locales)
|
||||
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
|
||||
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
|
||||
- **pnpm 11.1.2** is managed via corepack (`corepack enable`).
|
||||
|
||||
### Codebase map
|
||||
|
||||
- `apps/web` owns TanStack Start routes, Vite config, PWA setup, oRPC browser client wiring, web features, and the resume builder UI.
|
||||
- `apps/server` owns the production Hono app, route composition, auth/RPC/MCP/OpenAPI handlers, static uploads, schema JSON, web-dist fallback serving, and startup checks.
|
||||
- `packages/api` contains oRPC routers, DTOs, rate limiting, and feature-owned API modules under `packages/api/src/features/*`. The router export at `@reactive-resume/api/routers` aggregates those feature routers for `/api/rpc`.
|
||||
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The server auth adapter in `apps/server/src/http/auth.ts` delegates to `auth.handler`.
|
||||
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
|
||||
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
|
||||
- `packages/schema` contains Zod schemas and typed resume/page/template models.
|
||||
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, template implementations, and browser/server PDF generation adapters. PDF.js viewer UI stays in `apps/web`.
|
||||
- `packages/resume` contains pure resume-domain behavior such as JSON Patch helpers and social-network icon mapping.
|
||||
- `packages/docx` contains DOCX export generation.
|
||||
- `packages/mcp` contains MCP tools, prompts, resources, server-card generation, and tool metadata.
|
||||
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
|
||||
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, and `packages/config` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
|
||||
- Development-only scripts live in `tooling/`, not under `packages/`, so packages only contain code bundled by the app/runtime.
|
||||
|
||||
### Web app conventions
|
||||
|
||||
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
|
||||
- Server-owned HTTP behavior lives in `apps/server/src/{http,rpc,mcp,openapi,static,startup}`. Keep API/RPC/auth/MCP/static route wiring in `apps/server`, not in web routes.
|
||||
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
|
||||
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
|
||||
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
|
||||
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
|
||||
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
|
||||
|
||||
```ts
|
||||
type IntentSelectFieldProps<TValue extends string> = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
};
|
||||
|
||||
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Key Config Files
|
||||
### Package and feature boundaries
|
||||
|
||||
- `vite.config.ts` — Vite + Nitro + TanStack Start + PWA + Tailwind + Lingui
|
||||
- `drizzle.config.ts` — PostgreSQL dialect, schema at `./src/integrations/drizzle/schema.ts`
|
||||
- `tsconfig.json` — ES2022, strict mode, path alias `@/*` → `./src/*`
|
||||
- `lingui.config.ts` — i18n extraction and locale configuration
|
||||
- `components.json` — shadcn CLI configuration
|
||||
- Workspace dependencies must go through package names and package export maps. Do not import another workspace's `src` tree through repository paths, `@reactive-resume/*/src/*`, or TypeScript path aliases.
|
||||
- `turbo boundaries` is the executable package-boundary check. Workspace-level `turbo.json` files declare coarse tags:
|
||||
- `app:web` for the TanStack Start app.
|
||||
- `app:server` and `runtime:server` for the Node/Hono process.
|
||||
- `runtime:server` for server-only packages such as API/auth/db/env/email/MCP.
|
||||
- `runtime:browser` for browser-only shared UI.
|
||||
- `runtime:universal` for environment-neutral domain packages.
|
||||
- `role:domain`, `role:infra`, `role:adapter`, `role:api`, `role:rendering`, and `role:tooling` for package intent.
|
||||
- Browser/server runtime-specific code should live behind explicit export subpaths such as `@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, or `@reactive-resume/env/server`. Keep root exports environment-neutral unless the package is intentionally server-only.
|
||||
- Wildcard exports are allowed only for leaf libraries whose public surface is intentionally file-like, currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages that own runtime behavior.
|
||||
- Add new API procedures and business logic inside the owning `packages/api/src/features/*` module. Keep route wiring, DTO usage, helpers, and services colocated by feature/capability, then expose only intentional public surfaces through `packages/api/package.json`. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
|
||||
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `dotenvx run -f .env.local -- pnpm db:generate`.
|
||||
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
|
||||
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
|
||||
- Resume JSON Patch behavior belongs in `@reactive-resume/resume/patch`; do not put resume-domain helpers in `@reactive-resume/utils`.
|
||||
- DOCX export behavior belongs in `@reactive-resume/docx`; do not put DOCX builders in `@reactive-resume/utils`.
|
||||
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
|
||||
- PDF generation helpers live behind `@reactive-resume/pdf/browser` and `@reactive-resume/pdf/server`; locale-specific section-title resolution stays in the caller.
|
||||
- MCP implementation belongs in `@reactive-resume/mcp`; app packages must not import MCP implementation from another app's source tree.
|
||||
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
|
||||
|
||||
### API Architecture
|
||||
Placement decision tree:
|
||||
|
||||
- **oRPC API** (`/api/rpc/*`) — Type-safe RPC with routers for: `ai`, `auth`, `resume`, `storage`, `printer`, `jobs`, `statistics`, `flags`. Three procedure types: `publicProcedure`, `protectedProcedure`, `serverOnlyProcedure`.
|
||||
- **Better Auth API** (`/api/auth/*`) — OAuth, session management, social provider callbacks.
|
||||
- **MCP Server** (`/mcp/`) — Model Context Protocol with OAuth Bearer tokens and API key auth. Exposes resumes as resources and tools for resume CRUD.
|
||||
1. If the change is a web route, route loader, or user-facing web workflow, start in `apps/web/src/routes` or `apps/web/src/features`.
|
||||
2. If the change is a server HTTP route/adapter, startup check, static handler, MCP transport, or OpenAPI/well-known handler, start in `apps/server/src`.
|
||||
3. If it is authenticated API behavior, put the contract and implementation in the owning `packages/api/src/features/*` module.
|
||||
4. If it is pure resume data behavior with no DB, HTTP, DOM, or PDF renderer dependency, put it in `packages/resume`.
|
||||
5. If it renders resume PDFs, put shared React PDF/template code in `packages/pdf`; put PDF.js viewer/canvas UI in `apps/web/src/features/resume`.
|
||||
6. If it creates DOCX exports, put it in `packages/docx`.
|
||||
7. If it exposes MCP tools/prompts/resources, put it in `packages/mcp`.
|
||||
8. If it is a generic UI primitive or hook, put it in `packages/ui`; if it is workflow-specific UI, keep it in the owning web feature.
|
||||
9. If it is a narrow cross-cutting helper, add an explicit `packages/utils` export only after checking that no domain package is a better owner.
|
||||
|
||||
## Infrastructure Services
|
||||
### Database
|
||||
|
||||
Before running the dev server, Docker must be running with at least PostgreSQL. Start services via `compose.dev.yml`:
|
||||
PostgreSQL runs via Docker Compose:
|
||||
|
||||
```bash
|
||||
sudo dockerd &>/var/log/dockerd.log &
|
||||
sudo docker compose -f compose.dev.yml up -d postgres browserless
|
||||
```
|
||||
sudo docker compose -f compose.dev.yml up -d postgres
|
||||
```
|
||||
|
||||
- **PostgreSQL** (port 5432) — required. The app auto-runs Drizzle migrations on startup via a Nitro plugin.
|
||||
- **Browserless** (port 4000) — required for PDF export. Maps container port 3000 to host port 4000.
|
||||
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||
|
||||
## Environment Variables
|
||||
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. Run migration commands through `dotenvx`, for example `dotenvx run -f .env.local -- pnpm db:migrate`, so `DATABASE_URL` is present in the process environment.
|
||||
|
||||
Copy `.env.example` to `.env` if not present. Key notes for local dev:
|
||||
The production server runs migrations during startup before serving traffic. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||
|
||||
- `APP_URL` — local dev server origin on port 3000.
|
||||
- `PRINTER_APP_URL` — must use the Docker bridge gateway IP (not localhost) so the Browserless container can reach the app on the host. Get the IP with: `sudo docker network inspect reactive_resume_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'`
|
||||
- `PRINTER_ENDPOINT` — websocket URL to Browserless on host port 4000 with token `1234567890`.
|
||||
- `DATABASE_URL` — PostgreSQL connection using `postgres:postgres` credentials on localhost:5432.
|
||||
- S3/Storage and SMTP vars can be left empty — the app falls back to local filesystem and console-logged emails.
|
||||
### Environment
|
||||
|
||||
## Common Commands
|
||||
Copy `.env.example` to `.env`. The three required variables are:
|
||||
|
||||
`vp` is the global CLI for Vite+. Do not use pnpm/npm/yarn directly — Vite+ wraps the underlying package manager.
|
||||
- `APP_URL` (default `http://localhost:3000`)
|
||||
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
|
||||
- `AUTH_SECRET` (any non-empty string)
|
||||
|
||||
| Task | Command |
|
||||
| ----------------------------- | --------------------------------------------------------------- |
|
||||
| Install dependencies | `vp install` |
|
||||
| Dev server (port 3000) | `vp dev` |
|
||||
| Lint (Oxlint, type-aware) | `vp lint --type-aware` |
|
||||
| Format (Oxfmt) | `vp fmt` |
|
||||
| Check (lint + format + types) | `vp check` |
|
||||
| Typecheck | `pnpm typecheck` (uses tsgo) |
|
||||
| Run tests | `vp test` |
|
||||
| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) |
|
||||
| DB studio | `pnpm db:studio` |
|
||||
| i18n extraction | `pnpm lingui:extract` |
|
||||
| Add a dependency | `vp add <package>` |
|
||||
| Remove a dependency | `vp remove <package>` |
|
||||
| One-off binary | `vp dlx <package>` |
|
||||
| Build for production | `vp build` |
|
||||
| Preview production build | `vp preview` |
|
||||
| Start production server | `pnpm start` |
|
||||
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||
|
||||
## Vite+ Pitfalls
|
||||
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
|
||||
|
||||
- **Do not use pnpm/npm/yarn directly** for package operations — use `vp add`, `vp remove`, `vp install`, etc.
|
||||
- **Do not run `vp vitest` or `vp oxlint`** — they don't exist. Use `vp test` and `vp lint`.
|
||||
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly** — Vite+ bundles them.
|
||||
- **Import from `vite-plus`**, not from `vite` or `vitest` directly (e.g., `import { defineConfig } from 'vite-plus'`).
|
||||
- **Vite+ commands take precedence** over `package.json` scripts. If there's a naming conflict, use `vp run <script>`.
|
||||
- **Use `vp dlx`** instead of `npx` or `pnpm dlx`.
|
||||
- **Type-aware linting** works out of the box with `vp lint --type-aware` — no need to install `oxlint-tsgolint`.
|
||||
### Common commands
|
||||
|
||||
## Gotchas
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install deps | `pnpm install` |
|
||||
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
|
||||
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
|
||||
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
|
||||
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
|
||||
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
|
||||
| Web dev server only | `dotenvx run -f .env.local -- pnpm dev:web` |
|
||||
| Lint/format | `pnpm check` (Biome) |
|
||||
| Boundary check | `pnpm exec turbo boundaries` |
|
||||
| Tests | `pnpm test` (Vitest) |
|
||||
| Build | `pnpm build` |
|
||||
| Typecheck | `pnpm typecheck` |
|
||||
|
||||
- The Docker daemon needs `fuse-overlayfs` storage driver and `iptables-legacy` in the cloud VM (nested container environment).
|
||||
- `pnpm.onlyBuiltDependencies` in `package.json` controls which packages are allowed to run install scripts — no interactive `pnpm approve-builds` needed.
|
||||
- Email verification is optional in dev — after signup, click "Continue" to skip.
|
||||
- Vite and Nitro use beta/nightly builds. Occasional upstream issues may occur.
|
||||
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||
|
||||
## Review Checklist for Agents
|
||||
```
|
||||
pnpm --filter web typecheck
|
||||
pnpm --filter @reactive-resume/pdf test
|
||||
pnpm --filter @reactive-resume/api test
|
||||
pnpm exec turbo boundaries
|
||||
```
|
||||
|
||||
- [ ] Run `vp install` after pulling remote changes and before getting started.
|
||||
- [ ] Run `pnpm lint:fix`, `pnpm format:fix`, `pnpm typecheck` and `vp test` to validate changes.
|
||||
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The server startup path auto-runs migrations before serving traffic, so `pnpm db:migrate` is mainly needed for first-time setup, migration debugging, or applying migrations without starting the app.
|
||||
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
|
||||
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
|
||||
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
|
||||
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
|
||||
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
|
||||
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
|
||||
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Reactive Resume
|
||||
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
|
||||
colors:
|
||||
primary: "#343434"
|
||||
primary-foreground: "#FBFBFB"
|
||||
secondary: "#F7F7F7"
|
||||
secondary-foreground: "#343434"
|
||||
background: "#FFFFFF"
|
||||
foreground: "#252525"
|
||||
muted: "#F7F7F7"
|
||||
muted-foreground: "#8E8E8E"
|
||||
card: "#FFFFFF"
|
||||
card-foreground: "#252525"
|
||||
border: "#EBEBEB"
|
||||
input: "#EBEBEB"
|
||||
ring: "#B5B5B5"
|
||||
destructive: "#DC2626"
|
||||
on-destructive: "#FFFFFF"
|
||||
typography:
|
||||
heading:
|
||||
fontFamily: IBM Plex Sans Variable
|
||||
fontSize: 1rem
|
||||
fontWeight: 500
|
||||
body:
|
||||
fontFamily: IBM Plex Sans Variable
|
||||
fontSize: 0.875rem
|
||||
fontWeight: 400
|
||||
body-sm:
|
||||
fontFamily: IBM Plex Sans Variable
|
||||
fontSize: 0.75rem
|
||||
fontWeight: 400
|
||||
label:
|
||||
fontFamily: IBM Plex Sans Variable
|
||||
fontSize: 0.8rem
|
||||
fontWeight: 500
|
||||
hero-heading:
|
||||
fontFamily: IBM Plex Sans Variable
|
||||
fontSize: 3.75rem
|
||||
fontWeight: 700
|
||||
letterSpacing: -0.025em
|
||||
rounded:
|
||||
sm: 0.18rem
|
||||
md: 0.24rem
|
||||
lg: 0.3rem
|
||||
xl: 0.42rem
|
||||
2xl: 0.54rem
|
||||
3xl: 0.66rem
|
||||
4xl: 0.78rem
|
||||
spacing:
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 16px
|
||||
lg: 24px
|
||||
xl: 32px
|
||||
2xl: 48px
|
||||
components:
|
||||
button-default:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 10px
|
||||
height: 36px
|
||||
button-outline:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 10px
|
||||
height: 36px
|
||||
button-secondary:
|
||||
backgroundColor: "{colors.secondary}"
|
||||
textColor: "{colors.secondary-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 10px
|
||||
height: 36px
|
||||
button-ghost:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 10px
|
||||
height: 36px
|
||||
button-destructive:
|
||||
backgroundColor: "{colors.destructive}"
|
||||
textColor: "{colors.on-destructive}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 10px
|
||||
height: 36px
|
||||
card:
|
||||
backgroundColor: "{colors.card}"
|
||||
textColor: "{colors.card-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
input:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 36px
|
||||
padding: 10px
|
||||
input-focus:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 36px
|
||||
padding: 10px
|
||||
badge:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 4px
|
||||
popover:
|
||||
backgroundColor: "{colors.card}"
|
||||
textColor: "{colors.card-foreground}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: 4px
|
||||
sidebar:
|
||||
backgroundColor: "{colors.muted}"
|
||||
textColor: "{colors.foreground}"
|
||||
padding: 8px
|
||||
sidebar-item:
|
||||
backgroundColor: "{colors.muted}"
|
||||
textColor: "{colors.muted-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 8px
|
||||
sidebar-item-active:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 8px
|
||||
tooltip:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 6px
|
||||
separator:
|
||||
backgroundColor: "{colors.border}"
|
||||
height: 1px
|
||||
dialog:
|
||||
backgroundColor: "{colors.card}"
|
||||
textColor: "{colors.card-foreground}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: 24px
|
||||
input-invalid:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.destructive}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 36px
|
||||
padding: 10px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
|
||||
|
||||
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
|
||||
|
||||
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
|
||||
|
||||
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
|
||||
|
||||
## Colors
|
||||
|
||||
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
|
||||
|
||||
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
|
||||
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
|
||||
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
|
||||
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
|
||||
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
|
||||
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
|
||||
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
|
||||
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
|
||||
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
|
||||
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
|
||||
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
|
||||
|
||||
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
|
||||
|
||||
## Typography
|
||||
|
||||
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
|
||||
|
||||
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
|
||||
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
|
||||
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
|
||||
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
|
||||
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
|
||||
|
||||
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
|
||||
|
||||
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
|
||||
|
||||
## Layout
|
||||
|
||||
### Builder (Three-Panel Workspace)
|
||||
|
||||
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
|
||||
|
||||
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
|
||||
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
|
||||
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
|
||||
|
||||
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
|
||||
|
||||
### Dashboard
|
||||
|
||||
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
|
||||
|
||||
### Landing Page
|
||||
|
||||
Full-width single-column marketing layout:
|
||||
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
|
||||
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
|
||||
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
|
||||
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
|
||||
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
|
||||
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
|
||||
|
||||
### Page Aspect Ratio
|
||||
|
||||
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
|
||||
|
||||
## Animation
|
||||
|
||||
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
|
||||
|
||||
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
|
||||
|
||||
**Timing principles:**
|
||||
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
|
||||
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
|
||||
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
|
||||
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
|
||||
|
||||
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
|
||||
|
||||
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
|
||||
|
||||
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Elevation is handled through background color layering rather than drop shadows:
|
||||
|
||||
- **Level 0 — Background:** The base canvas (`--background`).
|
||||
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
|
||||
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
|
||||
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
|
||||
|
||||
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
|
||||
|
||||
## Shapes
|
||||
|
||||
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|:------|:------|:------|
|
||||
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
|
||||
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
|
||||
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
|
||||
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
|
||||
| `2xl` | 0.54rem (≈9px) | Dialog containers |
|
||||
| `3xl` | 0.66rem (≈11px) | Large panels |
|
||||
| `4xl` | 0.78rem (≈12px) | Full-page modals |
|
||||
|
||||
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
|
||||
|
||||
- **Default:** Solid primary background. The highest-emphasis action on any screen.
|
||||
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
|
||||
- **Secondary:** Muted background. For paired actions alongside a primary button.
|
||||
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
|
||||
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
|
||||
- **Link:** Underline-on-hover text. For inline navigation within prose.
|
||||
|
||||
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
|
||||
|
||||
### Cards
|
||||
|
||||
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
|
||||
|
||||
### Forms
|
||||
|
||||
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
|
||||
|
||||
### Dialogs
|
||||
|
||||
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
|
||||
|
||||
### Command Palette
|
||||
|
||||
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
|
||||
|
||||
### Toast Notifications
|
||||
|
||||
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
|
||||
|
||||
### Drag and Drop
|
||||
|
||||
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
|
||||
|
||||
## Internationalization
|
||||
|
||||
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
|
||||
|
||||
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
|
||||
|
||||
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
|
||||
|
||||
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
|
||||
|
||||
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
|
||||
|
||||
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
|
||||
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
|
||||
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
|
||||
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
|
||||
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
|
||||
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
|
||||
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
|
||||
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
|
||||
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
|
||||
|
||||
### Don't
|
||||
|
||||
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
|
||||
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
|
||||
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
|
||||
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
|
||||
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
|
||||
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
|
||||
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
|
||||
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
|
||||
+50
-37
@@ -1,39 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------- Dependencies Layer ----------
|
||||
FROM node:24-slim AS dependencies
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
RUN mkdir -p /tmp/dev /tmp/prod
|
||||
|
||||
COPY package.json pnpm-lock.yaml /tmp/dev/
|
||||
COPY package.json pnpm-lock.yaml /tmp/prod/
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
cd /tmp/dev && pnpm install --frozen-lockfile
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
cd /tmp/prod && pnpm install --frozen-lockfile --prod
|
||||
|
||||
# ---------- Builder Layer ----------
|
||||
FROM node:24-slim AS builder
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /tmp/dev/node_modules ./node_modules
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm dlx turbo@2.9.12 prune web server --docker
|
||||
|
||||
RUN pnpm run build
|
||||
FROM base AS builder
|
||||
COPY --from=pruner /app/out/json/ ./
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ---------- Runtime Layer ----------
|
||||
FROM node:24-slim AS runtime
|
||||
COPY --from=pruner /app/out/full/ ./
|
||||
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
|
||||
|
||||
FROM base AS runtime-pruner
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm dlx turbo@2.9.12 prune server --docker
|
||||
|
||||
FROM base AS runtime-deps
|
||||
COPY --from=runtime-pruner /app/out/json/ ./
|
||||
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS runtime
|
||||
|
||||
LABEL maintainer="amruthpillai"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
@@ -44,20 +49,28 @@ LABEL org.opencontainers.image.url="https://rxresu.me"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
|
||||
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV="production" \
|
||||
PORT=3000 \
|
||||
LOCAL_STORAGE_PATH=/app/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
COPY --from=dependencies /tmp/prod/node_modules ./node_modules
|
||||
COPY --from=runtime-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
||||
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
|
||||
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
|
||||
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
|
||||
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`).then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
||||
|
||||
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||
CMD ["node", "apps/server/dist/index.mjs"]
|
||||
|
||||
+34
-12
@@ -1,21 +1,43 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------- Development Layer ----------
|
||||
FROM node:24-slim AS development
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
ENV NODE_ENV="development"
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
FROM node:${NODE_VERSION}-slim AS dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
NODE_ENV=development \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
RUN corepack enable
|
||||
|
||||
EXPOSE 3000
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY patches ./patches
|
||||
COPY apps/server/package.json ./apps/server/package.json
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
COPY packages/ai/package.json ./packages/ai/package.json
|
||||
COPY packages/api/package.json ./packages/api/package.json
|
||||
COPY packages/auth/package.json ./packages/auth/package.json
|
||||
COPY packages/config/package.json ./packages/config/package.json
|
||||
COPY packages/db/package.json ./packages/db/package.json
|
||||
COPY packages/email/package.json ./packages/email/package.json
|
||||
COPY packages/env/package.json ./packages/env/package.json
|
||||
COPY packages/fonts/package.json ./packages/fonts/package.json
|
||||
COPY packages/import/package.json ./packages/import/package.json
|
||||
COPY packages/pdf/package.json ./packages/pdf/package.json
|
||||
COPY packages/schema/package.json ./packages/schema/package.json
|
||||
COPY packages/ui/package.json ./packages/ui/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY tooling/package.json ./tooling/package.json
|
||||
|
||||
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000/tcp 3001/tcp
|
||||
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<a href="https://rxresu.me">
|
||||
<img src="public/opengraph/banner.jpg" alt="Reactive Resume" />
|
||||
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
|
||||
</a>
|
||||
|
||||
<h1>Reactive Resume</h1>
|
||||
@@ -14,14 +14,14 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume version">
|
||||
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
|
||||
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
|
||||
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
|
||||
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
|
||||
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
|
||||
<a href="https://github.com/sponsors/AmruthPillai"><img src="https://img.shields.io/github/sponsors/AmruthPillai?style=flat-square&label=sponsors" alt="Sponsors" /></a>
|
||||
<a href="https://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
|
||||
<a href="https://opencollective.com/reactive-resume/donate"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,26 @@ Reactive Resume makes building resumes straightforward. Pick a template, fill in
|
||||
|
||||
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Reactive Resume stays free, open-source, and independent because companies choose to support the work behind it. Thank you to every sponsor who helps fund hosting, maintenance, and continued development for the community.
|
||||
|
||||
<p>
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume">
|
||||
<img src="apps/web/public/sponsors/atlas-cloud-logo-white.svg" alt="Atlas Cloud" width="320" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume) supports Reactive Resume as a project sponsor. Atlas Cloud provides a unified AI platform for developers, with access to hundreds of models for chat, image generation, video generation, media processing, and GPU cloud workloads through one API key, one endpoint, and one billing account.
|
||||
|
||||
If your company would like to sponsor Reactive Resume, email [hello@amruthpillai.com](mailto:hello@amruthpillai.com).
|
||||
|
||||
## Features
|
||||
|
||||
**Resume Building**
|
||||
|
||||
- Real-time preview as you type
|
||||
- Multiple export formats (PDF, JSON)
|
||||
- Multiple export formats (PDF, JSON, DOCX)
|
||||
- Drag-and-drop section ordering
|
||||
- Custom sections for any content type
|
||||
- Rich text editor with formatting support
|
||||
@@ -69,63 +83,71 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/azurill.jpg" alt="Azurill" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/azurill.jpg" alt="Azurill" width="150" />
|
||||
<br /><sub><b>Azurill</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
|
||||
<br /><sub><b>Bronzor</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/chikorita.jpg" alt="Chikorita" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/chikorita.jpg" alt="Chikorita" width="150" />
|
||||
<br /><sub><b>Chikorita</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/ditto.jpg" alt="Ditto" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/ditto.jpg" alt="Ditto" width="150" />
|
||||
<br /><sub><b>Ditto</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
|
||||
<br /><sub><b>Gengar</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/glalie.jpg" alt="Glalie" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/glalie.jpg" alt="Glalie" width="150" />
|
||||
<br /><sub><b>Glalie</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
|
||||
<br /><sub><b>Kakuna</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/lapras.jpg" alt="Lapras" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/lapras.jpg" alt="Lapras" width="150" />
|
||||
<br /><sub><b>Lapras</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
|
||||
<br /><sub><b>Leafish</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/onyx.jpg" alt="Onyx" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/onyx.jpg" alt="Onyx" width="150" />
|
||||
<br /><sub><b>Onyx</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
|
||||
<br /><sub><b>Pikachu</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/rhyhorn.jpg" alt="Rhyhorn" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/rhyhorn.jpg" alt="Rhyhorn" width="150" />
|
||||
<br /><sub><b>Rhyhorn</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
|
||||
<img src="apps/web/public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
|
||||
<br /><sub><b>Ditgar</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="apps/web/public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
|
||||
<br /><sub><b>Meowth</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
|
||||
<br /><sub><b>Scizor</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -135,7 +157,7 @@ The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/amruthpillai/reactive-resume.git
|
||||
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||
cd reactive-resume
|
||||
|
||||
# Start all services
|
||||
@@ -160,7 +182,7 @@ For detailed setup instructions, environment configuration, and self-hosting gui
|
||||
| API | ORPC (Type-safe RPC) |
|
||||
| Auth | Better Auth |
|
||||
| Styling | Tailwind CSS |
|
||||
| UI Components | Radix UI |
|
||||
| UI Components | Base UI + shadcn-style package |
|
||||
| State Management | Zustand + TanStack Query |
|
||||
|
||||
## Documentation
|
||||
@@ -180,9 +202,10 @@ Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
Reactive Resume can be self-hosted using Docker. The stack includes:
|
||||
|
||||
- **PostgreSQL** — Database for storing user data and resumes
|
||||
- **Printer** — Headless Chromium service for PDF and screenshot generation
|
||||
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
||||
|
||||
> **From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
|
||||
|
||||
Pull the latest image from Docker Hub or GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
@@ -203,7 +226,7 @@ Reactive Resume is and always will be free and open-source. If it has helped you
|
||||
<a href="https://github.com/sponsors/AmruthPillai">
|
||||
<img src="https://img.shields.io/badge/GitHub%20Sponsors-Support-ea4aaa?style=flat-square&logo=github-sponsors" alt="GitHub Sponsors" />
|
||||
</a>
|
||||
<a href="https://opencollective.com/reactive-resume">
|
||||
<a href="https://opencollective.com/reactive-resume/donate">
|
||||
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsdown",
|
||||
"start": "node dist/index.mjs",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"imports": {
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^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",
|
||||
"@orpc/json-schema": "^1.14.6",
|
||||
"@orpc/openapi": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@orpc/zod": "^1.14.6",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/mcp": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^7.0.15",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.23",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.27",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^8.0.4",
|
||||
"nodemailer": "^9.0.3",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"pg": "^8.22.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.7",
|
||||
"react-email": "^6.6.6",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.3",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleAuth: vi.fn(),
|
||||
handleOAuth: vi.fn(),
|
||||
handleRpc: vi.fn(),
|
||||
handleOpenApi: vi.fn(),
|
||||
handleHealth: vi.fn(),
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
handleOpenIdConfiguration: vi.fn(),
|
||||
handleWellKnownFallback: vi.fn(),
|
||||
handleRobots: vi.fn(),
|
||||
handleSitemap: vi.fn(),
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
handleAuth: mocks.handleAuth,
|
||||
handleOAuth: mocks.handleOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./health", () => ({
|
||||
handleHealth: mocks.handleHealth,
|
||||
}));
|
||||
|
||||
vi.mock("../rpc/handler", () => ({
|
||||
handleRpc: mocks.handleRpc,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/handler", () => ({
|
||||
handleOpenApi: mocks.handleOpenApi,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/metadata", () => ({
|
||||
handleMcpServerCard: mocks.handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
|
||||
handleWellKnownFallback: mocks.handleWellKnownFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../static/uploads", () => ({
|
||||
handleUpload: mocks.handleUpload,
|
||||
}));
|
||||
|
||||
vi.mock("../static/seo", () => ({
|
||||
handleRobots: mocks.handleRobots,
|
||||
handleSitemap: mocks.handleSitemap,
|
||||
handleLlms: mocks.handleLlms,
|
||||
}));
|
||||
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
handleMcp: mocks.handleMcp,
|
||||
}));
|
||||
|
||||
vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
|
||||
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
|
||||
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
|
||||
mocks.handleRobots.mockReturnValue(new Response("robots"));
|
||||
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
|
||||
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("oauth");
|
||||
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes signed resume PDF downloads before the web fallback", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("pdf");
|
||||
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
|
||||
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
|
||||
["GET", "/llms.txt", "llms", mocks.handleLlms],
|
||||
["HEAD", "/llms.txt", "", mocks.handleLlms],
|
||||
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request(`http://localhost:3001${pathname}`, { method });
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe(expectedBody);
|
||||
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
import {
|
||||
handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration,
|
||||
handleWellKnownFallback,
|
||||
} from "../openapi/metadata";
|
||||
import { handleRpc } from "../rpc/handler";
|
||||
import { handleSchemaJson } from "../static/schema";
|
||||
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||
import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/schema.json", () => handleSchemaJson());
|
||||
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
||||
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
|
||||
|
||||
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
||||
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
|
||||
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
|
||||
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
|
||||
app.all("/.well-known/*", () => handleWellKnownFallback());
|
||||
|
||||
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
handler: vi.fn(),
|
||||
env: {
|
||||
SERVER_PORT: 3001,
|
||||
APP_URL: "http://localhost:3000",
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
||||
mocks.handler.mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "invalid_redirect_uri",
|
||||
error_description: "redirect_uri is not allowed",
|
||||
});
|
||||
expect(mocks.handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
||||
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOAuth", () => {
|
||||
it("redirects unauthenticated users to the same-origin login route", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await handleOAuth(
|
||||
new Request(
|
||||
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get("Location");
|
||||
expect(location).toMatch(/^\/auth\/login\?/);
|
||||
|
||||
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
||||
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
||||
|
||||
expect(loginUrl.origin).toBe("http://localhost:3000");
|
||||
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
||||
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
||||
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
||||
expect(callbackUrl.searchParams.has("exp")).toBe(false);
|
||||
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
"prompt",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"response_type",
|
||||
"scope",
|
||||
"state",
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||
try {
|
||||
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||
if (URL.canParse(decodedRedirectUri)) {
|
||||
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||
}
|
||||
}
|
||||
|
||||
if (url.toString() === request.url) return request;
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
}
|
||||
|
||||
return new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||
if (request.method !== "POST") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
||||
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||
for (const redirectUri of redirectUris) {
|
||||
if (
|
||||
typeof redirectUri !== "string" ||
|
||||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
|
||||
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
|
||||
})
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAuth(request: Request) {
|
||||
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||
if (registrationValidationError) return registrationValidationError;
|
||||
|
||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export async function handleOAuth(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000);
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function getCookie(request: Request, name: string): string | undefined {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) return;
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||
if ([...headers].length === 0) return response;
|
||||
|
||||
const nextHeaders = new Headers(response.headers);
|
||||
for (const [key, value] of headers) nextHeaders.append(key, value);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: nextHeaders,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
type CheckResult = {
|
||||
status: "healthy" | "unhealthy";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// 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 latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
return { ...(data as object), status: "healthy", latencyMs };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
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() {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
return getStorageService().healthcheck();
|
||||
}
|
||||
|
||||
export async function handleHealth() {
|
||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const body = JSON.stringify(checks);
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
|
||||
|
||||
return new Response(body, {
|
||||
headers,
|
||||
status: checks.status === "unhealthy" ? 503 : 200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createResumePdfDownload: vi.fn(),
|
||||
verifyResumePdfDownloadToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
createResumePdfDownload: mocks.createResumePdfDownload,
|
||||
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
|
||||
}));
|
||||
|
||||
const { handleResumePdfDownload } = await import("./resume-pdf");
|
||||
|
||||
describe("handleResumePdfDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the PDF when the signed URL token is valid", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
|
||||
});
|
||||
|
||||
it("passes the cover letter target through to PDF rendering", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
let response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(410);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response("Download link expired", {
|
||||
status: 410,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown) {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
return code === "NOT_FOUND" ? 404 : 500;
|
||||
}
|
||||
|
||||
export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const target = searchParams.get("target") === "cover-letter" ? "cover-letter" : "resume";
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
"Content-Type": download.body.type || "application/pdf",
|
||||
"Content-Disposition": download.headers["content-disposition"],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PDF Download]", error);
|
||||
return new Response("Failed to generate resume PDF", {
|
||||
status: errorStatus(error),
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { createApp } from "./http/app";
|
||||
import { runStartupChecks } from "./startup/checks";
|
||||
|
||||
export { createApp } from "./http/app";
|
||||
|
||||
async function main() {
|
||||
await runStartupChecks();
|
||||
|
||||
const port =
|
||||
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
|
||||
|
||||
const app = createApp();
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
(info) => {
|
||||
console.info(`🚀 Up and running on http://localhost:${info.port}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateRequest(request: Request): Promise<void> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (payload?.sub) return;
|
||||
} catch {
|
||||
// Invalid or expired token; fall through to API key auth.
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
|
||||
if (result.valid) return;
|
||||
} catch {
|
||||
// Invalid or malformed key; fall through to AuthError.
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { AuthError, authenticateRequest } from "./auth";
|
||||
import { createMcpServer } from "./server";
|
||||
|
||||
export async function handleMcp(request: Request) {
|
||||
try {
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = await createMcpServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
return await transport.handleRequest(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return Response.json(
|
||||
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[MCP]", error);
|
||||
|
||||
return Response.json({
|
||||
id: null,
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { onError } from "@orpc/client";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||
import { appVersion } from "../app-version";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
return createRouterClient(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[MCP oRPC]", error);
|
||||
}),
|
||||
],
|
||||
context: () => ({
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders: new Headers(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
version: appVersion,
|
||||
title: "Reactive Resume",
|
||||
websiteUrl: "https://rxresu.me",
|
||||
description:
|
||||
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
|
||||
icons: [
|
||||
{
|
||||
src: "https://rxresu.me/icon/light.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
src: "https://rxresu.me/icon/dark.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "dark",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
);
|
||||
|
||||
const client = createRequestClient(request);
|
||||
registerResources(server, client);
|
||||
registerTools(server, client, request.headers);
|
||||
registerPrompts(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
new BatchHandlerPlugin(),
|
||||
new RequestHeadersPlugin(),
|
||||
new StrictGetMethodPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[OpenAPI]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request) {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: appVersion,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
|
||||
return Response.json(spec);
|
||||
}
|
||||
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export function handleWellKnownFallback() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
export function handleMcpServerCard() {
|
||||
return Response.json(buildMcpServerCard(appVersion), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function handleOAuthAuthorizationServer(request: Request) {
|
||||
return oauthAuthorizationServerHandler(request);
|
||||
}
|
||||
|
||||
export function handleOpenIdConfiguration(request: Request) {
|
||||
return openIdConfigurationHandler(request);
|
||||
}
|
||||
|
||||
export async function handleOAuthProtectedResource() {
|
||||
const metadata = {
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
};
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "./locale";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[oRPC Server]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request) {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
import { getCookie } from "../http/headers";
|
||||
|
||||
export function getRequestLocale(request: Request): Locale {
|
||||
const locale = getCookie(request, "locale");
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { constants, existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||
|
||||
function resolveFromCurrentModule(relativePath: string) {
|
||||
return fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
}
|
||||
|
||||
function resolveWorkspaceFolder(folderName: string): string {
|
||||
let dir = resolveFromCurrentModule(".");
|
||||
|
||||
while (dir !== path.dirname(dir)) {
|
||||
const candidate = path.join(dir, folderName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||
}
|
||||
|
||||
async function runDatabaseMigrations() {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
const db = drizzle({ client: pool });
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateLocalStoragePath() {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dataDirectory, { recursive: true });
|
||||
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(
|
||||
`Local storage path is not writable: ${dataDirectory}\n` +
|
||||
` ${message}\n` +
|
||||
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runStartupChecks() {
|
||||
await runDatabaseMigrations();
|
||||
await validateLocalStoragePath();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
|
||||
return Response.json(resumeDataJSONSchema, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: appVersion,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: {
|
||||
APP_URL: "https://app.example.com/",
|
||||
},
|
||||
}));
|
||||
|
||||
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
|
||||
|
||||
describe("SEO static endpoints", () => {
|
||||
it("generates robots.txt from the normalized app URL", async () => {
|
||||
const response = handleRobots();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(text).toContain("User-agent: *");
|
||||
expect(text).toContain("Allow: /");
|
||||
expect(text).toContain("Disallow: /api/rpc");
|
||||
expect(text).toContain("Disallow: /api/auth");
|
||||
expect(text).toContain("Disallow: /mcp");
|
||||
expect(text).toContain("Disallow: /.well-known");
|
||||
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
|
||||
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
|
||||
});
|
||||
|
||||
it("generates an app-domain-only sitemap", async () => {
|
||||
const response = handleSitemap();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
|
||||
expect(text).toContain("<loc>https://app.example.com/</loc>");
|
||||
expect(text).not.toContain("docs.rxresu.me");
|
||||
expect(text).not.toContain("/auth");
|
||||
expect(text).not.toContain("/dashboard");
|
||||
expect(text).not.toContain("/builder");
|
||||
expect(text).not.toContain("/templates");
|
||||
expect(text).not.toContain("/schema.json");
|
||||
});
|
||||
|
||||
it("generates a lightweight llms.txt product index", async () => {
|
||||
const response = handleLlms();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(text).toContain("# Reactive Resume");
|
||||
expect(text).toContain("- Product: https://app.example.com");
|
||||
expect(text).toContain("- Documentation: https://docs.rxresu.me");
|
||||
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
|
||||
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
|
||||
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
|
||||
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
|
||||
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
|
||||
});
|
||||
|
||||
it("returns headers without a body for HEAD responses", async () => {
|
||||
const response = handleLlms({ head: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const DOCS_URL = "https://docs.rxresu.me";
|
||||
|
||||
type StaticSeoOptions = {
|
||||
head?: boolean;
|
||||
};
|
||||
|
||||
function appUrl() {
|
||||
return env.APP_URL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function textResponse(body: string, options: StaticSeoOptions = {}) {
|
||||
return new Response(options.head ? null : body, {
|
||||
headers: { "Content-Type": "text/plain; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleRobots(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"Disallow: /api/rpc",
|
||||
"Disallow: /api/auth",
|
||||
"Disallow: /mcp",
|
||||
"Disallow: /.well-known",
|
||||
"",
|
||||
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||
`Sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return textResponse(body, options);
|
||||
}
|
||||
|
||||
export function handleSitemap(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/</loc>`,
|
||||
" </url>",
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return new Response(options?.head ? null : body, {
|
||||
headers: { "Content-Type": "application/xml; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleLlms(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"# Reactive Resume",
|
||||
"",
|
||||
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
|
||||
"",
|
||||
"## Links",
|
||||
"",
|
||||
`- Product: ${baseUrl}`,
|
||||
`- Documentation: ${DOCS_URL}`,
|
||||
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
|
||||
`- API documentation: ${DOCS_URL}/api-reference`,
|
||||
`- Resume schema: ${baseUrl}/schema.json`,
|
||||
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
|
||||
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return textResponse(body, options);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readMock = vi.fn();
|
||||
|
||||
vi.mock("@reactive-resume/api/features/storage", () => ({
|
||||
getStorageService: () => ({
|
||||
read: readMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: {
|
||||
APP_URL: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
const { handleUpload } = await import("./uploads");
|
||||
|
||||
describe("handleUpload", () => {
|
||||
beforeEach(() => {
|
||||
readMock.mockReset();
|
||||
});
|
||||
|
||||
it("serves public upload keys", async () => {
|
||||
readMock.mockResolvedValueOnce({
|
||||
data: new TextEncoder().encode("image"),
|
||||
size: 5,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
||||
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
||||
readMock.mockResolvedValueOnce({
|
||||
data: new TextEncoder().encode("secret"),
|
||||
size: 6,
|
||||
contentType: "text/plain",
|
||||
});
|
||||
|
||||
const response = await handleUpload(
|
||||
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(readMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
|
||||
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
||||
|
||||
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
||||
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const storageService = getStorageService();
|
||||
const key = `uploads/${userId}/${filePath}`;
|
||||
const storedFile = await storageService.read(key);
|
||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
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 = 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 new Response(toArrayBuffer(storedFile.data), { headers });
|
||||
}
|
||||
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
const pathname = new URL(url).pathname;
|
||||
const [, pathAfterUploads] = pathname.split("/uploads/");
|
||||
if (!pathAfterUploads) return { userId: undefined, filePath: undefined };
|
||||
const firstSlashIndex = pathAfterUploads.indexOf("/");
|
||||
|
||||
if (firstSlashIndex === -1) {
|
||||
return { userId: pathAfterUploads, filePath: undefined };
|
||||
}
|
||||
|
||||
const userId = pathAfterUploads.slice(0, firstSlashIndex);
|
||||
const filePath = pathAfterUploads.slice(firstSlashIndex + 1);
|
||||
|
||||
return { userId, filePath: filePath || undefined };
|
||||
}
|
||||
|
||||
function isValidPath(segment: string): boolean {
|
||||
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
|
||||
return normalized === segment;
|
||||
}
|
||||
|
||||
function isValidPathSegments(path: string): boolean {
|
||||
const segments = path.split("/");
|
||||
|
||||
return segments.every((segment) => isValidPath(segment));
|
||||
}
|
||||
|
||||
function isPrivateUploadPath(path: string): boolean {
|
||||
return path.split("/")[0] === "agent";
|
||||
}
|
||||
|
||||
function isNotModified(headers: Headers, etag: string): boolean {
|
||||
const ifNoneMatch = headers.get("If-None-Match");
|
||||
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
||||
|
||||
return candidates.includes(etag);
|
||||
}
|
||||
|
||||
function makeNotModifiedResponse(etag: string): Response {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: { ETag: etag, "Cache-Control": "public, max-age=31536000, immutable" },
|
||||
});
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
: (data.slice().buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
||||
if (storedFile.etag) {
|
||||
const tag = storedFile.etag.trim();
|
||||
|
||||
if (tag.startsWith("W/") || tag.startsWith('"')) {
|
||||
return tag;
|
||||
}
|
||||
return `"${tag}"`;
|
||||
}
|
||||
|
||||
const hash = createHash("sha256").update(storedFile.data).digest("hex");
|
||||
|
||||
return `"${storedFile.size}-${hash}"`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const { handleWebApp } = await import("./web");
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
});
|
||||
|
||||
it("serves the shell for the root app route without noindex", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/auth/login",
|
||||
"/dashboard",
|
||||
"/builder/resume-1",
|
||||
"/agent",
|
||||
"/templates",
|
||||
"/templates/azurill.pdf",
|
||||
])("serves noindex shell for known app prefix %s", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it("serves noindex shell for public resume shaped routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it("returns noindex 404 for unknown non-asset routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/foo",
|
||||
"/mcp/foo",
|
||||
"/uploads/foo",
|
||||
])("does not treat reserved two-segment path %s as a public resume", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns plain 404 for missing asset-looking paths", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
||||
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");
|
||||
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await knownResponse.text()).toBe("");
|
||||
|
||||
expect(unknownResponse.status).toBe(404);
|
||||
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await unknownResponse.text()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
|
||||
function resolveWebDistPath() {
|
||||
const candidates = [
|
||||
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
|
||||
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
|
||||
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
|
||||
fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
];
|
||||
const [fallback] = candidates;
|
||||
if (!fallback) throw new Error("Could not resolve web dist path");
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
|
||||
}
|
||||
|
||||
const staticRoot = resolveWebDistPath();
|
||||
const indexHtmlPath = `${staticRoot}/index.html`;
|
||||
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
||||
const reservedPublicResumeSegments = new Set([
|
||||
"api",
|
||||
"mcp",
|
||||
".well-known",
|
||||
"uploads",
|
||||
"auth",
|
||||
"dashboard",
|
||||
"builder",
|
||||
"agent",
|
||||
"templates",
|
||||
]);
|
||||
|
||||
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
|
||||
function getPathSegments(pathname: string) {
|
||||
return pathname.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function isNoindexShellPath(pathname: string): boolean {
|
||||
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function isPublicResumePath(pathname: string): boolean {
|
||||
const segments = getPathSegments(pathname);
|
||||
const [firstSegment] = segments;
|
||||
|
||||
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
||||
}
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
|
||||
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
||||
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
|
||||
return new Response(options.head ? null : "Not Found", {
|
||||
status: 404,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// 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(isHead ? null : "Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
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 });
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tsdown.config.ts", "vitest.config.ts"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { TsdownPlugin } from "tsdown";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const shouldExternalizeThirdParty = (id: string) => {
|
||||
if (id.startsWith("@reactive-resume/")) return false;
|
||||
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const aiPromptsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../packages/ai/src/prompts");
|
||||
|
||||
const promptAssetsPlugin: TsdownPlugin = {
|
||||
name: "prompt-assets",
|
||||
buildStart() {
|
||||
for (const filename of readdirSync(aiPromptsDir)) {
|
||||
if (!filename.endsWith(".md")) continue;
|
||||
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: `prompts/${filename}`,
|
||||
source: readFileSync(resolve(aiPromptsDir, filename), "utf-8"),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
shims: true,
|
||||
dts: false,
|
||||
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
|
||||
outExtensions: () => ({ js: ".mjs" }),
|
||||
deps: {
|
||||
alwaysBundle: [/^@reactive-resume\//],
|
||||
neverBundle: shouldExternalizeThirdParty,
|
||||
},
|
||||
plugins: [promptAssetsPlugin],
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @boundaries-ignore root shared Vitest config
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "server",
|
||||
dirname: fileURLToPath(new URL(".", import.meta.url)),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "phosphor",
|
||||
"aliases": {
|
||||
"components": "@reactive-resume/ui/components",
|
||||
"utils": "@reactive-resume/utils/style",
|
||||
"hooks": "@reactive-resume/ui/hooks",
|
||||
"lib": "@reactive-resume/utils",
|
||||
"ui": "@reactive-resume/ui/components"
|
||||
},
|
||||
"rtl": true,
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#09090B" />
|
||||
<meta name="application-name" content="Reactive Resume" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.">
|
||||
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-po";
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: "en-US",
|
||||
pseudoLocale: "zu-ZA",
|
||||
locales: [
|
||||
"af-ZA",
|
||||
"am-ET",
|
||||
"ar-SA",
|
||||
"az-AZ",
|
||||
"bg-BG",
|
||||
"bn-BD",
|
||||
"ca-ES",
|
||||
"cs-CZ",
|
||||
"da-DK",
|
||||
"de-DE",
|
||||
"el-GR",
|
||||
"en-US",
|
||||
"en-GB",
|
||||
"es-ES",
|
||||
"fa-IR",
|
||||
"fi-FI",
|
||||
"fr-FR",
|
||||
"he-IL",
|
||||
"hi-IN",
|
||||
"hu-HU",
|
||||
"id-ID",
|
||||
"it-IT",
|
||||
"ja-JP",
|
||||
"km-KH",
|
||||
"kn-IN",
|
||||
"ko-KR",
|
||||
"lt-LT",
|
||||
"lv-LV",
|
||||
"ml-IN",
|
||||
"mr-IN",
|
||||
"ms-MY",
|
||||
"ne-NP",
|
||||
"nl-NL",
|
||||
"no-NO",
|
||||
"or-IN",
|
||||
"pl-PL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sk-SK",
|
||||
"sl-SI",
|
||||
"sq-AL",
|
||||
"sr-SP",
|
||||
"sv-SE",
|
||||
"ta-IN",
|
||||
"te-IN",
|
||||
"th-TH",
|
||||
"tr-TR",
|
||||
"uk-UA",
|
||||
"uz-UZ",
|
||||
"vi-VN",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"zu-ZA",
|
||||
],
|
||||
fallbackLocales: {
|
||||
"zu-ZA": "en-US",
|
||||
default: "en-US",
|
||||
},
|
||||
format: formatter({
|
||||
lineNumbers: false,
|
||||
}),
|
||||
catalogs: [
|
||||
{
|
||||
path: "<rootDir>/locales/{locale}",
|
||||
include: ["src"],
|
||||
},
|
||||
],
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user