mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
fix: make monorepo checks and caching consistent
This commit is contained in:
+25
-25
@@ -29,21 +29,21 @@
|
||||
"@ai-sdk/togetherai": "^3.0.60",
|
||||
"@ai-sdk/xai": "^5.0.12",
|
||||
"@aws-sdk/client-s3": "^3.1143.0",
|
||||
"@better-auth/api-key": "^1.7.6",
|
||||
"@better-auth/api-key": "catalog:",
|
||||
"@better-auth/drizzle-adapter": "^1.7.6",
|
||||
"@better-auth/infra": "^0.4.13",
|
||||
"@better-auth/oauth-provider": "^1.7.6",
|
||||
"@better-auth/passkey": "^1.7.6",
|
||||
"@better-auth/infra": "catalog:",
|
||||
"@better-auth/oauth-provider": "catalog:",
|
||||
"@better-auth/passkey": "catalog:",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@formepdf/core": "0.25.0",
|
||||
"@formepdf/react": "0.25.0",
|
||||
"@hono/node-server": "^2.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"@orpc/client": "^1.15.4",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/experimental-ratelimit": "^1.15.4",
|
||||
"@orpc/json-schema": "^1.15.4",
|
||||
"@orpc/openapi": "^1.15.4",
|
||||
"@orpc/server": "^1.15.4",
|
||||
"@orpc/server": "catalog:",
|
||||
"@orpc/zod": "^1.15.4",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
@@ -54,50 +54,50 @@
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.1",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/color-convert": "catalog:",
|
||||
"@vercel/blob": "^2.8.0",
|
||||
"@vercel/functions": "^3.9.9",
|
||||
"ai": "^7.0.122",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.7.6",
|
||||
"ai": "catalog:",
|
||||
"bcryptjs": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"cjk-regex": "^3.5.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"es-toolkit": "catalog:",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fast-png": "^8.0.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fflate": "catalog:",
|
||||
"firecrawl": "^4.42.0",
|
||||
"hono": "^4.13.11",
|
||||
"ioredis": "^6.0.0",
|
||||
"ioredis": "catalog:",
|
||||
"jose": "^6.2.12",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"jsonrepair": "catalog:",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"nodemailer": "^10.0.12",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pg": "^8.23.0",
|
||||
"react": "^19.3.0",
|
||||
"pg": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-email": "^6.11.0",
|
||||
"react-reconciler": "0.34.0",
|
||||
"resumable-stream": "^2.2.13",
|
||||
"sanitize-html": "^2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"tokenx": "^2.1.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ts-pattern": "catalog:",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.6.5"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"@types/node": "catalog:",
|
||||
"@types/pg": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tsdown": "^0.23.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,12 +2,18 @@
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:agent": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:ci": {
|
||||
"cache": false,
|
||||
"env": ["OAUTH_TEST_DATABASE_URL"]
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -18,10 +18,10 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.125",
|
||||
"@base-ui/react": "^1.8.0",
|
||||
"@better-auth/api-key": "^1.7.6",
|
||||
"@better-auth/infra": "^0.4.13",
|
||||
"@better-auth/oauth-provider": "^1.7.6",
|
||||
"@better-auth/passkey": "^1.7.6",
|
||||
"@better-auth/api-key": "catalog:",
|
||||
"@better-auth/infra": "catalog:",
|
||||
"@better-auth/oauth-provider": "catalog:",
|
||||
"@better-auth/passkey": "catalog:",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -35,8 +35,8 @@
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@lingui/core": "^6.8.0",
|
||||
"@lingui/react": "^6.8.0",
|
||||
"@orpc/client": "^1.15.4",
|
||||
"@orpc/server": "^1.15.4",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/server": "catalog:",
|
||||
"@orpc/tanstack-query": "^1.15.4",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
@@ -66,13 +66,13 @@
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/color-convert": "catalog:",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^7.0.122",
|
||||
"better-auth": "1.7.6",
|
||||
"ai": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"fflate": "^0.8.3",
|
||||
"es-toolkit": "catalog:",
|
||||
"fflate": "catalog:",
|
||||
"fuse.js": "^7.5.0",
|
||||
"immer": "^11.1.18",
|
||||
"js-cookie": "^3.0.8",
|
||||
@@ -80,15 +80,15 @@
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"prettier": "^3.9.9",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.3.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-easy-crop": "^6.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-window": "^2.3.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ts-pattern": "catalog:",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.6.5",
|
||||
"zod": "catalog:",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -105,13 +105,13 @@
|
||||
"@tanstack/react-router-devtools": "^1.167.2",
|
||||
"@tanstack/router-plugin": "^1.168.41",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^8.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,10 @@ export function ChipInput({
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(index: number) => {
|
||||
const chip = chips[index];
|
||||
if (chip === undefined) return;
|
||||
setEditingIndex(index);
|
||||
setInput(chips[index]);
|
||||
setInput(chip);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[chips],
|
||||
@@ -244,7 +246,7 @@ export function ChipInput({
|
||||
(newOrder: string[]) => {
|
||||
if (editingIndex !== null) {
|
||||
const editingChip = chips[editingIndex];
|
||||
const newIndex = newOrder.indexOf(editingChip);
|
||||
const newIndex = editingChip === undefined ? -1 : newOrder.indexOf(editingChip);
|
||||
if (newIndex !== -1 && newIndex !== editingIndex) {
|
||||
setEditingIndex(newIndex);
|
||||
}
|
||||
@@ -282,6 +284,7 @@ export function ChipInput({
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const newOrder = [...chips];
|
||||
const [removed] = newOrder.splice(oldIndex, 1);
|
||||
if (removed === undefined) return;
|
||||
newOrder.splice(newIndex, 0, removed);
|
||||
handleReorder(newOrder);
|
||||
}
|
||||
@@ -307,7 +310,7 @@ export function ChipInput({
|
||||
if (newValue.includes(",")) {
|
||||
const parts = newValue.split(",");
|
||||
addChips(parts.slice(0, -1));
|
||||
setInput(parts[parts.length - 1]);
|
||||
setInput(parts.at(-1) ?? "");
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ interface FontDisplayProps {
|
||||
family: string;
|
||||
label: string;
|
||||
type: "standard" | "web";
|
||||
url?: string;
|
||||
url?: string | undefined;
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>();
|
||||
|
||||
@@ -22,7 +22,7 @@ export function getNextWeights(fontFamily: string, single = false): Weight[] | n
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!selectedWeights.has(candidate)) {
|
||||
if (candidate !== undefined && !selectedWeights.has(candidate)) {
|
||||
weights.push(candidate);
|
||||
selectedWeights.add(candidate);
|
||||
} else break;
|
||||
|
||||
@@ -31,8 +31,8 @@ type ComboboxOption<TValue extends string | number = string> = {
|
||||
|
||||
type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue | null;
|
||||
defaultValue?: TValue | null;
|
||||
value?: TValue | null | undefined;
|
||||
defaultValue?: TValue | null | undefined;
|
||||
onValueChange?: (value: TValue | null) => void;
|
||||
multiple?: false;
|
||||
disabled?: boolean;
|
||||
@@ -47,8 +47,8 @@ type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
|
||||
type MultiComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue[] | null;
|
||||
defaultValue?: TValue[] | null;
|
||||
value?: TValue[] | null | undefined;
|
||||
defaultValue?: TValue[] | null | undefined;
|
||||
onValueChange?: (value: TValue[] | null) => void;
|
||||
multiple: true;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -230,7 +230,8 @@ export function mapCsvToApplications(table: string[][], mapping?: readonly (CsvF
|
||||
const parsed = applicationStatusSchema.safeParse(stage === "rejected" ? "closed" : stage);
|
||||
if (parsed.success) record.status = parsed.data;
|
||||
} else if (field === "stageEnteredAt") {
|
||||
record.stageEnteredAt = dateOnly(value);
|
||||
const date = dateOnly(value);
|
||||
if (date !== undefined) record.stageEnteredAt = date;
|
||||
} else record[field] = value as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentUIMessage } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import type { ChatTransport, FileUIPart, UIMessage } from "ai";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
|
||||
import { lastAssistantMessageIsCompleteWithToolCalls, parseJsonEventStream, uiMessageChunkSchema } from "ai";
|
||||
@@ -84,9 +84,9 @@ export function useAssistantChat({ threadId, initialMessages, resume, context, o
|
||||
contextRef.current = context;
|
||||
});
|
||||
|
||||
const transport = useMemo(
|
||||
const transport = useMemo<ChatTransport<AgentUIMessage>>(
|
||||
() => ({
|
||||
async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal; body?: object }) {
|
||||
async sendMessages(options) {
|
||||
const message = options.messages.at(-1);
|
||||
if (!message) throw new Error("No message to send.");
|
||||
const attachmentIds = attachmentIdsOf(options.body);
|
||||
@@ -94,7 +94,7 @@ export function useAssistantChat({ threadId, initialMessages, resume, context, o
|
||||
eventIteratorToUnproxiedDataStream(
|
||||
await streamClient.agent.messages.send(
|
||||
{ threadId, message, context: contextRef.current, ...(attachmentIds ? { attachmentIds } : {}) },
|
||||
{ signal: options.abortSignal },
|
||||
options.abortSignal ? { signal: options.abortSignal } : {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -262,6 +262,11 @@ function toProposals(
|
||||
const output = part.output as ProposeEditsOutput | undefined;
|
||||
return (output?.edits ?? []).map((edit) => ({
|
||||
...edit,
|
||||
target: {
|
||||
sectionId: edit.target.sectionId,
|
||||
field: edit.target.field,
|
||||
...(edit.target.itemId === undefined ? {} : { itemId: edit.target.itemId }),
|
||||
},
|
||||
location: document.locationOf(edit) ?? edit.location,
|
||||
status: statuses.get(edit.id) ?? edit.status,
|
||||
source: "assistant",
|
||||
|
||||
@@ -56,7 +56,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const { callbackURL } = useSearch({ from: "/auth" });
|
||||
|
||||
const runSignIn = async (
|
||||
fn: () => Promise<{ data?: unknown; error: { message?: string } | null }>,
|
||||
fn: () => Promise<{ data?: unknown; error: { message?: string | undefined } | null }>,
|
||||
isPasskey = false,
|
||||
) => {
|
||||
const toastId = toast.add({ type: "loading", description: t`Signing in...` });
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useCommandPaletteStore } from "./store";
|
||||
export function CommandPalette() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const commandRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<string>();
|
||||
const [selectedValue, setSelectedValue] = useState("");
|
||||
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
|
||||
|
||||
const isFirstPage = pages.length === 0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Resume } from "./draft";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
@@ -165,10 +165,12 @@ describe("builder resume autosave", () => {
|
||||
settled = true;
|
||||
return blocked;
|
||||
});
|
||||
assert.exists(completions[0]);
|
||||
completions[0](withBasicsName(initial, "First"));
|
||||
await flushMicrotasks();
|
||||
expect(settled).toBe(false);
|
||||
expect(orpcMocks.updateResume.mock.lastCall?.[0].data.basics.name).toBe("Latest");
|
||||
assert.exists(completions[1]);
|
||||
completions[1](withBasicsName(initial, "Latest"));
|
||||
expect(await result).toBe(false);
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Latest");
|
||||
|
||||
@@ -22,9 +22,9 @@ export type Resume = {
|
||||
isLocked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
hasPassword?: boolean;
|
||||
isPublic?: boolean;
|
||||
showDownloadButtons?: boolean;
|
||||
hasPassword?: boolean | undefined;
|
||||
isPublic?: boolean | undefined;
|
||||
showDownloadButtons?: boolean | undefined;
|
||||
/** The job application this resume was made for; Check's Job match reads its posting. */
|
||||
applicationId?: string | null;
|
||||
};
|
||||
@@ -57,7 +57,7 @@ type UpdateResumeDataOptions = {
|
||||
|
||||
type ResumeStoreState = {
|
||||
resume: Resume | null;
|
||||
resumeId?: string;
|
||||
resumeId?: string | undefined;
|
||||
isReady: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
// Undo/redo stacks of `ResumeData` references. Immer's immutable updates share structure, so a
|
||||
@@ -90,21 +90,21 @@ type Runtime = {
|
||||
queryClient?: QueryClient;
|
||||
hasPendingLocalChanges: boolean;
|
||||
isSaving: boolean;
|
||||
pendingResume?: Resume;
|
||||
pendingResume?: Resume | undefined;
|
||||
/** The last save failed; don't loop on it. Cleared by the next edit, Retry, reconnecting or success. */
|
||||
saveFailed: boolean;
|
||||
slowSaveToastId?: string;
|
||||
slowSaveToastId?: string | undefined;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
|
||||
beforeUnloadHandler?: () => void;
|
||||
onlineHandler?: () => void;
|
||||
deferredRemoteResume?: Resume;
|
||||
deferredFocusHandler?: () => void;
|
||||
deferredRemoteResume?: Resume | undefined;
|
||||
deferredFocusHandler?: (() => void) | undefined;
|
||||
/** This visit's saves share one autosave version in History. */
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type ResumeUpdateSubscriptionOptions = {
|
||||
resumeId?: string;
|
||||
resumeId?: string | undefined;
|
||||
onUpdate: (event: ResumeUpdateEvent) => Promise<void> | void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ type MonthYearInputProps = DateSettings & {
|
||||
value: YearMonth | null;
|
||||
disabled?: boolean;
|
||||
/** Shown instead of the value, e.g. "Present" while the entry is ongoing. */
|
||||
placeholderValue?: string;
|
||||
placeholderValue?: string | undefined;
|
||||
onCommit: (value: YearMonth | null) => void;
|
||||
onInvalid: (invalid: boolean) => void;
|
||||
};
|
||||
@@ -84,7 +84,7 @@ function MonthYearInput({
|
||||
type DatesFieldProps = DateSettings & {
|
||||
dates: ResumeDates;
|
||||
/** Awards, certifications and publications have one date. */
|
||||
single?: boolean;
|
||||
single?: boolean | undefined;
|
||||
onChange: (dates: ResumeDates) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ function Text({
|
||||
label,
|
||||
wide,
|
||||
autoFocus,
|
||||
}: FieldSetProps & { field: string; label: ReactNode; wide?: boolean; autoFocus?: boolean }) {
|
||||
}: FieldSetProps & { field: string; label: ReactNode; wide?: boolean; autoFocus?: boolean | undefined }) {
|
||||
return (
|
||||
<TextField
|
||||
label={label}
|
||||
@@ -308,7 +308,7 @@ export function EntryFields({
|
||||
type,
|
||||
autoFocus,
|
||||
...props
|
||||
}: FieldSetProps & { type: CustomSectionType; autoFocus?: boolean }) {
|
||||
}: FieldSetProps & { type: CustomSectionType; autoFocus?: boolean | undefined }) {
|
||||
switch (type) {
|
||||
case "experience":
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,7 @@ type TextFieldProps = Omit<ComponentProps<typeof Input>, "value" | "onChange"> &
|
||||
/** Checked after the field is first left, then as you type. */
|
||||
validate?: Validator;
|
||||
hint?: ReactNode;
|
||||
wide?: boolean;
|
||||
wide?: boolean | undefined;
|
||||
};
|
||||
|
||||
/** A labelled text field. Errors appear after the first blur and say how to fix them. */
|
||||
@@ -109,7 +109,7 @@ export function TextField({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={error ? [error] : undefined} />
|
||||
<FormMessage errors={error ? [error] : []} />
|
||||
{hint && !error && <FormDescription>{hint}</FormDescription>}
|
||||
</FormItem>
|
||||
);
|
||||
@@ -137,7 +137,7 @@ export function WebsiteField({ label, value, onChange, allowInlineLink = true }:
|
||||
<URLInput value={value} onChange={onChange} hideLabelButton={inline} onBlur={() => setTouched(true)} />
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={error ? [error] : undefined} />
|
||||
<FormMessage errors={error ? [error] : []} />
|
||||
</FormItem>
|
||||
|
||||
{allowInlineLink && (
|
||||
|
||||
@@ -74,11 +74,7 @@ export async function createExportFile(
|
||||
|
||||
const data = getResumeExportData(resume.data, target);
|
||||
if (format === "pdf") {
|
||||
return createResumePdfBlob(
|
||||
data,
|
||||
undefined,
|
||||
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
|
||||
);
|
||||
return createResumePdfBlob(data, undefined, target === "cover-letter" ? options : undefined);
|
||||
}
|
||||
|
||||
const resolveTitle = await createSectionTitleResolver(data);
|
||||
|
||||
@@ -26,14 +26,14 @@ type PdfCanvasDocumentProps = {
|
||||
|
||||
type PdfCanvasPageProps = {
|
||||
caption?: ReactNode;
|
||||
className?: string;
|
||||
className?: string | undefined;
|
||||
overlay?: ReactNode;
|
||||
document: PDFDocumentProxy;
|
||||
onLoadSuccess: (pageNumber: number, pageSize: PreviewPageSize) => void;
|
||||
onRenderSuccess?: () => void;
|
||||
pageNumber: number;
|
||||
pageScale: number;
|
||||
pageSize?: PreviewPageSize;
|
||||
pageSize?: PreviewPageSize | undefined;
|
||||
showPageNumbers: boolean;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => pdfjsMock.legacyModule);
|
||||
|
||||
describe("PDF.js browser entrypoints", () => {
|
||||
beforeEach(() => {
|
||||
pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc = undefined;
|
||||
delete pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc;
|
||||
pdfjsMock.legacyModule.getDocument.mockClear();
|
||||
pdfjsMock.loadingTask.destroy.mockClear();
|
||||
pdfjsMock.pdfDocument.getPage.mockClear();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageMap } from "@reactive-resume/pdf/page-map";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { MotionStyle } from "motion/react";
|
||||
import type { ResolvedResumePreviewProps } from "./preview.shared";
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -217,7 +217,7 @@ export function ResumePreviewClient({
|
||||
key={visiblePdf.id}
|
||||
aria-hidden={visiblePdf.phase !== "active"}
|
||||
data-resume-preview-template={visiblePdf.template}
|
||||
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
|
||||
style={{ "--resume-preview-page-gap": resolvedPageGap } as MotionStyle}
|
||||
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
|
||||
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
||||
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
||||
|
||||
@@ -7,11 +7,11 @@ import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewGapValue, getScaledPreviewPageSi
|
||||
|
||||
export type ResumePreviewProps = {
|
||||
className?: string;
|
||||
data?: ResumeData;
|
||||
data?: ResumeData | undefined;
|
||||
pageGap?: CSSProperties["gap"];
|
||||
pageLayout?: "horizontal" | "vertical";
|
||||
pageScale?: number;
|
||||
pageClassName?: string;
|
||||
pageClassName?: string | undefined;
|
||||
showPageNumbers?: boolean;
|
||||
/** Letters show the sender's header, which a document with only a letter otherwise leaves out. */
|
||||
includeCoverLetterHeader?: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, expect, it, vi } from "vitest";
|
||||
import { assert, beforeAll, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
@@ -19,9 +19,11 @@ function renderAccessibleText(data: typeof sampleResumeData) {
|
||||
it("exposes entry and subordinate-role headings with safe nested rich-text lists", () => {
|
||||
// Characterization before this change: item labels had no heading elements and rich-text lists were flattened into one paragraph.
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const item = data.sections.experience.items[0];
|
||||
assert.exists(item);
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
...data.sections.experience.items[0],
|
||||
...item,
|
||||
id: "hierarchy-item",
|
||||
company: "Acme Company",
|
||||
position: "",
|
||||
@@ -37,7 +39,7 @@ it("exposes entry and subordinate-role headings with safe nested rich-text lists
|
||||
],
|
||||
},
|
||||
{
|
||||
...data.sections.experience.items[0],
|
||||
...item,
|
||||
id: "blank-primary-item",
|
||||
company: "",
|
||||
position: "",
|
||||
|
||||
@@ -331,7 +331,7 @@ type AccessibleSectionProps = {
|
||||
title: string;
|
||||
hidden: boolean;
|
||||
items: CustomSectionItem[];
|
||||
keywordLayout?: "inline" | "list";
|
||||
keywordLayout?: "inline" | "list" | undefined;
|
||||
};
|
||||
|
||||
function AccessibleSection({ type, title, hidden, items, keywordLayout }: AccessibleSectionProps) {
|
||||
@@ -353,7 +353,7 @@ function AccessibleSection({ type, title, hidden, items, keywordLayout }: Access
|
||||
}
|
||||
|
||||
type ResumeAccessibleTextProps = {
|
||||
data?: ResumeData;
|
||||
data?: ResumeData | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ const UNITS = [
|
||||
/** Time since a moment in the largest whole unit, written short: "5m", "2h", "3d", "6w", "1y". */
|
||||
export function formatTimeSince(date: Date, locale: string, now = Date.now()) {
|
||||
const elapsed = Math.max(0, now - date.getTime());
|
||||
const { unit, ms } = UNITS.find(({ ms, below }) => elapsed / ms < below) ?? UNITS[UNITS.length - 1];
|
||||
const { unit, ms } = UNITS.find(({ ms, below }) => elapsed / ms < below) ?? UNITS[4];
|
||||
return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "narrow" }).format(Math.floor(elapsed / ms));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
value?: T | undefined;
|
||||
defaultValue?: T | undefined;
|
||||
}
|
||||
|
||||
type UseControlledStateProps<T, Rest extends unknown[] = []> = CommonControlledStateProps<T> & {
|
||||
onChange?: (value: T, ...args: Rest) => void;
|
||||
onChange?: ((value: T, ...args: Rest) => void) | undefined;
|
||||
};
|
||||
|
||||
export function useControlledState<T, Rest extends unknown[] = []>(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { produce } from "immer";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
@@ -41,12 +41,14 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
it("restores the original JSON after moving an experience item to a new page and back", () => {
|
||||
const initial = base();
|
||||
const moved = split();
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
expect(moved.metadata.layout.pages).toHaveLength(2);
|
||||
const restored = produce(moved, (draft) => {
|
||||
moveItem(draft, {
|
||||
itemId: "2",
|
||||
type: "experience",
|
||||
customSectionId: moved.customSections[0].id,
|
||||
customSectionId: section.id,
|
||||
target: { type: "section", sectionId: "experience" },
|
||||
});
|
||||
});
|
||||
@@ -55,13 +57,16 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
it("preserves unrelated blank pages and pages with other section references", () => {
|
||||
const moved = produce(split(), (draft) => {
|
||||
draft.metadata.layout.pages.push({ fullWidth: true, main: [], sidebar: [] });
|
||||
assert.exists(draft.metadata.layout.pages[1]);
|
||||
draft.metadata.layout.pages[1].sidebar.push("skills");
|
||||
});
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
const restored = produce(moved, (draft) => {
|
||||
moveItem(draft, {
|
||||
itemId: "2",
|
||||
type: "experience",
|
||||
customSectionId: moved.customSections[0].id,
|
||||
customSectionId: section.id,
|
||||
target: { type: "section", sectionId: "experience" },
|
||||
});
|
||||
});
|
||||
@@ -76,11 +81,13 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
const moved = produce(split(), (draft) => {
|
||||
draft.metadata.layout.pages.reverse();
|
||||
});
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
const restored = produce(moved, (draft) => {
|
||||
moveItem(draft, {
|
||||
itemId: "2",
|
||||
type: "experience",
|
||||
customSectionId: moved.customSections[0].id,
|
||||
customSectionId: section.id,
|
||||
target: { type: "section", sectionId: "experience" },
|
||||
});
|
||||
});
|
||||
@@ -93,16 +100,19 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
const moved = produce(split(), (draft) => {
|
||||
draft.metadata.layout.pages.push({ fullWidth: true, main: [], sidebar: ["skills"] });
|
||||
});
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
const restored = produce(moved, (draft) => {
|
||||
moveItem(draft, {
|
||||
itemId: "2",
|
||||
type: "experience",
|
||||
customSectionId: moved.customSections[0].id,
|
||||
customSectionId: section.id,
|
||||
target: { type: "new-section", pageIndex: 2, title: "Later" },
|
||||
});
|
||||
});
|
||||
expect(restored.metadata.layout.pages).toHaveLength(2);
|
||||
expect(restored.customSections).toHaveLength(1);
|
||||
assert.exists(restored.customSections[0]);
|
||||
expect(restored.customSections[0]).toMatchObject({ title: "Later", items: [company("2")] });
|
||||
expect(restored.metadata.layout.pages[1]).toEqual({
|
||||
fullWidth: true,
|
||||
@@ -112,16 +122,20 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
});
|
||||
it("keeps a custom section with hidden remaining items", () => {
|
||||
const moved = produce(split(), (draft) => {
|
||||
assert.exists(draft.customSections[0]);
|
||||
draft.customSections[0].items.push({ ...company("3"), hidden: true });
|
||||
});
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
const restored = produce(moved, (draft) => {
|
||||
moveItem(draft, {
|
||||
itemId: "2",
|
||||
type: "experience",
|
||||
customSectionId: moved.customSections[0].id,
|
||||
customSectionId: section.id,
|
||||
target: { type: "section", sectionId: "experience" },
|
||||
});
|
||||
});
|
||||
assert.exists(restored.customSections[0]);
|
||||
expect(restored.customSections[0].items).toEqual([{ ...company("3"), hidden: true }]);
|
||||
expect(restored.metadata.layout.pages).toHaveLength(2);
|
||||
});
|
||||
@@ -131,9 +145,11 @@ describe("moving the last custom-section item (#3180)", () => {
|
||||
{ type: "new-section", pageIndex: 99, title: "Missing" },
|
||||
] as const)("leaves data intact for an invalid destination $type", (target) => {
|
||||
const moved = split();
|
||||
const section = moved.customSections[0];
|
||||
assert.exists(section);
|
||||
expect(
|
||||
produce(moved, (draft) => {
|
||||
moveItem(draft, { itemId: "2", type: "experience", customSectionId: moved.customSections[0].id, target });
|
||||
moveItem(draft, { itemId: "2", type: "experience", customSectionId: section.id, target });
|
||||
}),
|
||||
).toEqual(moved);
|
||||
});
|
||||
|
||||
@@ -81,8 +81,7 @@ export function getCompatibleMoveTargets(
|
||||
const customSectionById = new Map(resumeData.customSections.map((section) => [section.id, section]));
|
||||
const result: MoveTargetPage[] = [];
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
||||
const page = pages[pageIndex];
|
||||
for (const [pageIndex, page] of pages.entries()) {
|
||||
const allSectionIds = [...page.main, ...page.sidebar];
|
||||
const compatibleSections: MoveTargetSection[] = [];
|
||||
|
||||
|
||||
@@ -108,8 +108,7 @@ export function LayoutPages() {
|
||||
if (location) return location;
|
||||
|
||||
// Search through all pages
|
||||
for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
|
||||
const page = layout.pages[pageIndex];
|
||||
for (const [pageIndex, page] of layout.pages.entries()) {
|
||||
const mainSections = new Set(page.main);
|
||||
const sidebarSections = new Set(page.sidebar);
|
||||
if (mainSections.has(id)) return { pageIndex, columnId: "main" };
|
||||
@@ -141,6 +140,7 @@ export function LayoutPages() {
|
||||
// Same location, reorder within column
|
||||
if (activeLocation.pageIndex === overLocation.pageIndex && activeLocation.columnId === overLocation.columnId) {
|
||||
const page = layout.pages[activeLocation.pageIndex];
|
||||
if (!page) return;
|
||||
const items = page[activeLocation.columnId];
|
||||
const oldIdx = items.indexOf(activeIdStr);
|
||||
let newIdx = items.indexOf(overIdStr);
|
||||
@@ -148,12 +148,9 @@ export function LayoutPages() {
|
||||
if (newIdx === -1) newIdx = items.length - 1;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const colOrder = draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId];
|
||||
draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId] = arrayMove(
|
||||
colOrder,
|
||||
oldIdx,
|
||||
newIdx,
|
||||
);
|
||||
const page = draft.metadata.layout.pages[activeLocation.pageIndex];
|
||||
if (!page) return;
|
||||
page[activeLocation.columnId] = arrayMove(page[activeLocation.columnId], oldIdx, newIdx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -161,6 +158,7 @@ export function LayoutPages() {
|
||||
// Different location, move between columns/pages
|
||||
const fromPage = layout.pages[activeLocation.pageIndex];
|
||||
const toPage = layout.pages[overLocation.pageIndex];
|
||||
if (!fromPage || !toPage) return;
|
||||
const fromItems = fromPage[activeLocation.columnId];
|
||||
const toItems = toPage[overLocation.columnId];
|
||||
const fromIdx = fromItems.indexOf(activeIdStr);
|
||||
@@ -172,6 +170,7 @@ export function LayoutPages() {
|
||||
updateResumeData((draft) => {
|
||||
const fromPageDraft = draft.metadata.layout.pages[activeLocation.pageIndex];
|
||||
const toPageDraft = draft.metadata.layout.pages[overLocation.pageIndex];
|
||||
if (!fromPageDraft || !toPageDraft) return;
|
||||
const from = fromPageDraft[activeLocation.columnId];
|
||||
const to = toPageDraft[overLocation.columnId];
|
||||
|
||||
@@ -201,6 +200,7 @@ export function LayoutPages() {
|
||||
// Find the first available page that isn't being deleted
|
||||
const targetPageIndex = pageIndex === 0 ? 1 : 0;
|
||||
const targetPage = draft.metadata.layout.pages[targetPageIndex];
|
||||
if (!pageToDelete || !targetPage) return;
|
||||
|
||||
// Move all sections from deleted page to target page
|
||||
targetPage.main.push(...pageToDelete.main);
|
||||
@@ -216,6 +216,7 @@ export function LayoutPages() {
|
||||
(pageIndex: number, fullWidth: boolean) => {
|
||||
updateResumeData((draft) => {
|
||||
const page = draft.metadata.layout.pages[pageIndex];
|
||||
if (!page) return;
|
||||
page.fullWidth = fullWidth;
|
||||
|
||||
if (fullWidth) {
|
||||
@@ -451,17 +452,20 @@ function MoveToSubmenu({ id, pageIndex, columnId }: MoveToSubmenuProps) {
|
||||
|
||||
const moveTo = (targetPageIndex: number, targetColumnId: ColumnId) => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const from = draft.metadata.layout.pages[pageIndex]?.[columnId];
|
||||
const to = draft.metadata.layout.pages[targetPageIndex]?.[targetColumnId];
|
||||
if (!from || !to) return;
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
draft.metadata.layout.pages[targetPageIndex][targetColumnId].push(id);
|
||||
to.push(id);
|
||||
});
|
||||
};
|
||||
|
||||
const moveToNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const from = draft.metadata.layout.pages[pageIndex]?.[columnId];
|
||||
if (!from) return;
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
@@ -34,8 +34,8 @@ const CLAMP_MAX = 100;
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
function PageSectionForm() {
|
||||
const resume = useResume();
|
||||
const page = resume?.data.metadata.page;
|
||||
const resume = useCurrentResume();
|
||||
const page = resume.data.metadata.page;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { FontFamilyCombobox, FontWeightCombobox } from "@/components/typography/combobox";
|
||||
import { getNextWeights } from "@/components/typography/get-next-weights";
|
||||
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
@@ -33,7 +33,7 @@ type FormValues = z.infer<typeof formSchema>;
|
||||
type FontWeight = FormValues["body"]["fontWeights"][number];
|
||||
type TypographyPrefix = "body" | "heading";
|
||||
|
||||
function useTypographyForm(typography: FormValues | undefined, persist: (data: FormValues) => void) {
|
||||
function useTypographyForm(typography: FormValues, persist: (data: FormValues) => void) {
|
||||
const form = useAppForm({
|
||||
defaultValues: typography,
|
||||
validators: { onChange: formSchema },
|
||||
@@ -48,8 +48,8 @@ function useTypographyForm(typography: FormValues | undefined, persist: (data: F
|
||||
type TypographyForm = ReturnType<typeof useTypographyForm>;
|
||||
|
||||
function TypographySectionForm() {
|
||||
const resume = useResume();
|
||||
const typography = resume?.data.metadata.typography;
|
||||
const resume = useCurrentResume();
|
||||
const typography = resume.data.metadata.typography;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
|
||||
+1
-12
@@ -1,24 +1,13 @@
|
||||
{
|
||||
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "@testing-library/jest-dom/vitest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -82,8 +82,10 @@ export default defineConfig({
|
||||
// `vite build` builds the app, then the marketing pages' server entry, then prerenders the pages with it.
|
||||
builder: {
|
||||
buildApp: async (builder) => {
|
||||
await builder.build(builder.environments.client);
|
||||
await builder.build(builder.environments.ssr);
|
||||
const { client, ssr } = builder.environments;
|
||||
if (!client || !ssr) throw new Error("Missing client or SSR build environment");
|
||||
await builder.build(client);
|
||||
await builder.build(ssr);
|
||||
await prerenderPages();
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user