Merge pull request #2146 from AmruthPillai/feat/custom-css

Implement Custom CSS Feature
This commit is contained in:
Amruth Pillai
2025-01-13 01:07:16 +01:00
committed by GitHub
23 changed files with 463 additions and 17 deletions

View File

@ -55,5 +55,11 @@ export const ArtboardPage = () => {
}
}, [metadata]);
return <Outlet />;
return (
<>
{metadata.css.visible && <style lang="css">{`[data-page] { ${metadata.css.value} }`}</style>}
<Outlet />
</>
);
};

View File

@ -0,0 +1,155 @@
code[class*="language-"],
pre[class*="language-"] {
color: #f8f8f2;
background: none;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #2b2b2b;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #d4d0ab;
}
.token.punctuation {
color: #fefefe;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #ffa07a;
}
.token.boolean,
.token.number {
color: #00e0e0;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #abe338;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
color: #00e0e0;
}
.token.atrule,
.token.attr-value,
.token.function {
color: #ffd700;
}
.token.keyword {
color: #00e0e0;
}
.token.regex,
.token.important {
color: #ffd700;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
@media screen and (forced-colors: active) {
code[class*="language-"],
pre[class*="language-"] {
color: windowText;
background: window;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: window;
}
.token.important {
background: highlight;
color: window;
font-weight: normal;
}
.token.atrule,
.token.attr-value,
.token.function,
.token.keyword,
.token.operator,
.token.selector {
font-weight: bold;
}
.token.attr-value,
.token.comment,
.token.doctype,
.token.function,
.token.keyword,
.token.operator,
.token.property,
.token.string {
color: highlight;
}
.token.attr-value,
.token.url {
font-weight: normal;
}
}

View File

@ -0,0 +1,167 @@
code[class*="language-"],
pre[class*="language-"] {
color: #393a34;
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
font-size: 0.9em;
line-height: 1.2em;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre > code[class*="language-"] {
font-size: 1em;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
background: #c1def1;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
background: #c1def1;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border: 1px solid #dddddd;
background-color: white;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.2em;
padding-top: 1px;
padding-bottom: 1px;
background: #f8f8f8;
border: 1px solid #dddddd;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #008000;
font-style: italic;
}
.token.namespace {
opacity: 0.7;
}
.token.string {
color: #a31515;
}
.token.punctuation,
.token.operator {
color: #393a34; /* no highlight */
}
.token.url,
.token.symbol,
.token.number,
.token.boolean,
.token.variable,
.token.constant,
.token.inserted {
color: #36acaa;
}
.token.atrule,
.token.keyword,
.token.attr-value,
.language-autohotkey .token.selector,
.language-json .token.boolean,
.language-json .token.number,
code[class*="language-css"] {
color: #0000ff;
}
.token.function {
color: #393a34;
}
.token.deleted,
.language-autohotkey .token.tag {
color: #9a050f;
}
.token.selector,
.language-autohotkey .token.keyword {
color: #00009f;
}
.token.important {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.class-name,
.language-json .token.property {
color: #2b91af;
}
.token.tag,
.token.selector {
color: #800000;
}
.token.attr-name,
.token.property,
.token.regex,
.token.entity {
color: #ff0000;
}
.token.directive.tag .tag {
background: #ffff00;
color: #393a34;
}
/* overrides color-values for the Line Numbers plugin
* http://prismjs.com/plugins/line-numbers/
*/
.line-numbers.line-numbers .line-numbers-rows {
border-right-color: #a5a5a5;
}
.line-numbers .line-numbers-rows > span:before {
color: #2b91af;
}
/* overrides color-values for the Line Highlight plugin
* http://prismjs.com/plugins/line-highlight/
*/
.line-highlight.line-highlight {
background: rgba(193, 222, 241, 0.2);
background: -webkit-linear-gradient(left, rgba(193, 222, 241, 0.2) 70%, rgba(221, 222, 241, 0));
background: linear-gradient(to right, rgba(193, 222, 241, 0.2) 70%, rgba(221, 222, 241, 0));
}

View File

@ -289,7 +289,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -314,7 +314,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -314,7 +314,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -315,7 +315,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -289,7 +289,7 @@
[[], []]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -288,7 +288,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -287,7 +287,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -289,7 +289,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -306,7 +306,7 @@
[["projects", "certifications", "skills", "languages", "references"], []]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -287,7 +287,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -315,7 +315,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -288,7 +288,7 @@
]
],
"css": {
"value": ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {

View File

@ -5,6 +5,7 @@ import { useRef } from "react";
import { Copyright } from "@/client/components/copyright";
import { ThemeSwitch } from "@/client/components/theme-switch";
import { CssSection } from "./sections/css";
import { ExportSection } from "./sections/export";
import { InformationSection } from "./sections/information";
import { LayoutSection } from "./sections/layout";
@ -37,6 +38,8 @@ export const RightSidebar = () => {
<Separator />
<ThemeSection />
<Separator />
<CssSection />
<Separator />
<PageSection />
<Separator />
<SharingSection />
@ -85,6 +88,13 @@ export const RightSidebar = () => {
scrollIntoView("#theme");
}}
/>
<SectionIcon
id="css"
name={t`Custom CSS`}
onClick={() => {
scrollIntoView("#css");
}}
/>
<SectionIcon
id="page"
name={t`Page`}

View File

@ -0,0 +1,58 @@
import { t } from "@lingui/macro";
import { useTheme } from "@reactive-resume/hooks";
import { Label, Switch } from "@reactive-resume/ui";
import Prism from "prismjs";
import { Helmet } from "react-helmet-async";
import CodeEditor from "react-simple-code-editor";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const CssSection = () => {
const { isDarkMode } = useTheme();
const setValue = useResumeStore((state) => state.setValue);
const css = useResumeStore((state) => state.resume.data.metadata.css);
return (
<section id="css" className="grid gap-y-6">
<Helmet>
{isDarkMode && <link rel="stylesheet" href="/styles/prism-dark.css" />}
{!isDarkMode && <link rel="stylesheet" href="/styles/prism-light.css" />}
</Helmet>
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("css")}
<h2 className="line-clamp-1 text-2xl font-bold lg:text-3xl">{t`Custom CSS`}</h2>
</div>
</header>
<main className="space-y-4">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.css.visible"
checked={css.visible}
onCheckedChange={(checked) => {
setValue("metadata.css.visible", checked);
}}
/>
<Label htmlFor="metadata.css.visible">{t`Apply Custom CSS`}</Label>
</div>
<div className="rounded border p-4">
<CodeEditor
tabSize={4}
value={css.value}
className="language-css font-mono"
highlight={(code) => Prism.highlight(code, Prism.languages.css, "css")}
onValueChange={(value) => {
setValue("metadata.css.value", value);
}}
/>
</div>
</main>
</section>
);
};

View File

@ -1,4 +1,5 @@
import {
Code,
DiamondsFour,
DownloadSimple,
IconProps,
@ -19,6 +20,7 @@ export type MetadataKey =
| "layout"
| "typography"
| "theme"
| "css"
| "page"
| "locale"
| "sharing"
@ -45,6 +47,9 @@ export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
case "theme": {
return <Palette size={18} {...props} />;
}
case "css": {
return <Code size={18} {...props} />;
}
case "page": {
return <ReadCvLogo size={18} {...props} />;
}

View File

@ -150,6 +150,17 @@ export class PrinterService {
return temporaryHtml_;
}, pageElement);
// Apply custom CSS if enabled
const css = resume.data.metadata.css;
if (css.visible) {
await page.evaluate((cssValue: string) => {
const styleTag = document.createElement("style");
styleTag.textContent = cssValue;
document.head.append(styleTag);
}, css.value);
}
const uint8array = await page.pdf({ width, height, printBackground: true });
const buffer = Buffer.from(uint8array);
pagesBuffer.push(buffer);

View File

@ -12,7 +12,7 @@ export const metadataSchema = z.object({
template: z.string().default("rhyhorn"),
layout: z.array(z.array(z.array(z.string()))).default(defaultLayout), // pages -> columns -> sections
css: z.object({
value: z.string().default(".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"),
value: z.string().default("* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"),
visible: z.boolean().default(false),
}),
page: z.object({
@ -50,7 +50,7 @@ export const defaultMetadata: Metadata = {
template: "rhyhorn",
layout: defaultLayout,
css: {
value: ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
visible: false,
},
page: {

View File

@ -308,7 +308,7 @@ export const sampleResume: ResumeData = {
],
],
css: {
value: ".section {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
visible: false,
},
page: {

View File

@ -1,7 +1,7 @@
{
"name": "@reactive-resume/source",
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
"version": "4.3.4",
"version": "4.3.5",
"license": "MIT",
"private": true,
"author": {
@ -75,6 +75,7 @@
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-local": "^1.0.38",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-is": "^18.3.1",
@ -215,6 +216,7 @@
"passport-local": "^1.0.0",
"pdf-lib": "^1.17.1",
"prisma": "^5.22.0",
"prismjs": "^1.29.0",
"puppeteer": "^23.11.1",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
@ -225,6 +227,7 @@
"react-parallax-tilt": "^1.7.272",
"react-resizable-panels": "^2.1.7",
"react-router": "^7.1.1",
"react-simple-code-editor": "^0.14.1",
"react-zoom-pan-pinch": "^3.6.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",

31
pnpm-lock.yaml generated
View File

@ -296,6 +296,9 @@ importers:
prisma:
specifier: ^5.22.0
version: 5.22.0
prismjs:
specifier: ^1.29.0
version: 1.29.0
puppeteer:
specifier: ^23.11.1
version: 23.11.1(typescript@5.7.3)
@ -326,6 +329,9 @@ importers:
react-router:
specifier: ^7.1.1
version: 7.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-simple-code-editor:
specifier: ^0.14.1
version: 0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-zoom-pan-pinch:
specifier: ^3.6.1
version: 3.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -507,6 +513,9 @@ importers:
'@types/passport-local':
specifier: ^1.0.38
version: 1.0.38
'@types/prismjs':
specifier: ^1.26.5
version: 1.26.5
'@types/react':
specifier: ^18.3.18
version: 18.3.18
@ -4405,6 +4414,9 @@ packages:
'@types/pg@8.6.1':
resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/prop-types@15.7.11':
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
@ -9398,6 +9410,10 @@ packages:
engines: {node: '>=16.13'}
hasBin: true
prismjs@1.29.0:
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
engines: {node: '>=6'}
proc-log@3.0.0:
resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -9693,6 +9709,12 @@ packages:
react-dom:
optional: true
react-simple-code-editor@0.14.1:
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-style-singleton@2.2.1:
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
@ -16161,6 +16183,8 @@ snapshots:
pg-protocol: 1.7.0
pg-types: 2.2.0
'@types/prismjs@1.26.5': {}
'@types/prop-types@15.7.11': {}
'@types/pug@2.0.10':
@ -22313,6 +22337,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
prismjs@1.29.0: {}
proc-log@3.0.0: {}
process-nextick-args@2.0.1: {}
@ -22712,6 +22738,11 @@ snapshots:
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-simple-code-editor@0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-style-singleton@2.2.1(@types/react@18.3.18)(react@18.3.1):
dependencies:
get-nonce: 1.0.1