Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
796dc2ca41 | ||
|
|
80bf41254b | ||
|
|
bbc38d2f09 | ||
|
|
775f625f6e | ||
|
|
2203933fac | ||
|
|
907e32a731 | ||
|
|
d6919e340b | ||
|
|
e05ae42fbb | ||
|
|
e99f39d139 | ||
|
|
a4e7d6680d | ||
|
|
77ad14b359 | ||
|
|
9b3916d43e | ||
|
|
29f1aaf0da | ||
|
|
73cef02a4f | ||
|
|
2cb1897922 | ||
|
|
d0af9f4b4f | ||
|
|
08e9c80037 | ||
|
|
a42dbcd452 | ||
|
|
d3102565e4 | ||
|
|
c0387298c5 | ||
|
|
9e1e593dc4 | ||
|
|
849aad6497 | ||
|
|
847d69b621 | ||
|
|
6ff754d125 | ||
|
|
ff3d4b1337 | ||
|
|
2d2d5cd505 | ||
|
|
c6e8df0a00 | ||
|
|
7df9b1e4b5 | ||
|
|
0b4bac62a7 | ||
|
|
82cb6e7590 | ||
|
|
46dac6d3b1 | ||
|
|
b0b0d27c1f | ||
|
|
b12d1184bc | ||
|
|
d6e0d9ac61 | ||
|
|
c19b9746c8 | ||
|
|
4ed6177aee | ||
|
|
5900de101c | ||
|
|
61b3324941 | ||
|
|
0e858c5967 | ||
|
|
8c968e92f4 | ||
|
|
bea8ff1beb | ||
|
|
85e0b0a96d | ||
|
|
1b266ba7ac | ||
|
|
1810dc8b07 | ||
|
|
06b9da39ed | ||
|
|
11373763fd | ||
|
|
b3224ce59b | ||
|
|
145b17de0f | ||
|
|
623c5f6f81 | ||
|
|
bcbe70d231 | ||
|
|
8b52b9d8fc | ||
|
|
4ecc66d081 | ||
|
|
10d58175f1 | ||
|
|
923f5f6173 | ||
|
|
9332e1e3ff | ||
|
|
77f31fa127 | ||
|
|
4fd43657dc | ||
|
|
d9a24448e8 | ||
|
|
69ff78b656 | ||
|
|
0794b5c955 | ||
|
|
5c986cc68d | ||
|
|
688c002822 | ||
|
|
ced49d5314 | ||
|
|
3653baad9b | ||
|
|
b4aaf9712f | ||
|
|
e3274d7c95 | ||
|
|
a7a3d53dbd | ||
|
|
f8d776106f | ||
|
|
e34f648455 | ||
|
|
f4e2d05f25 | ||
|
|
bbc17b8995 | ||
|
|
a66bc13f4f | ||
|
|
7858efbd2b | ||
|
|
7da5e2c0c2 | ||
|
|
177c550a0c | ||
|
|
882725cabf | ||
|
|
3e16586d7a | ||
|
|
56a1838315 | ||
|
|
2d76cc5cfb | ||
|
|
192880e416 | ||
|
|
d1dac8aeca | ||
|
|
f13093249d | ||
|
|
99c602e3c7 | ||
|
|
040755bec9 | ||
|
|
1063680774 | ||
|
|
1f8644bb54 | ||
|
|
1d7c4b2615 | ||
|
|
d8ffd00fa6 | ||
|
|
f431c0fd89 | ||
|
|
571a1c1efe | ||
|
|
547afaa18f | ||
|
|
7789c39fe3 | ||
|
|
bfb2e8bb7e | ||
|
|
189290e093 | ||
|
|
7b541f3567 | ||
|
|
5cd16a62d9 | ||
|
|
89beb43ea2 | ||
|
|
55757857bd | ||
|
|
584444c1fc | ||
|
|
fb61bb4a63 | ||
|
|
ea8fd838d9 | ||
|
|
a3c58b0068 | ||
|
|
e0e8879caf | ||
|
|
feab19913b | ||
|
|
afbe56af0d | ||
|
|
d22ab8202c | ||
|
|
f4b6db222c | ||
|
|
44177c0d1b | ||
|
|
1d59ea3bd0 | ||
|
|
5e9ae64b2b | ||
|
|
46392d9dfc | ||
|
|
6734da4bee | ||
|
|
27edc963ce | ||
|
|
4b46bda4e6 | ||
|
|
df81d0342f | ||
|
|
4825eed22c | ||
|
|
b0711351f7 | ||
|
|
b7e4c86f4e | ||
|
|
c25c068d20 | ||
|
|
d217d5dc4f | ||
|
|
1ed80140b5 | ||
|
|
28ae7714c8 | ||
|
|
9d70bc4fd3 | ||
|
|
e5a51013c1 | ||
|
|
eeaac9a86f | ||
|
|
269dbc600f | ||
|
|
9b29a44429 | ||
|
|
e9fa161148 | ||
|
|
299d6d4878 | ||
|
|
ea63ae6e72 | ||
|
|
4d50cd9b71 | ||
|
|
8b3f3bcc35 | ||
|
|
f73a5117a1 | ||
|
|
2d1e13b9b5 | ||
|
|
b6c274eeb6 | ||
|
|
4b78a7c9dd | ||
|
|
87e2f2f391 | ||
|
|
f237c42093 | ||
|
|
a80f86e99e | ||
|
|
eb62ed99ce | ||
|
|
45fc32323f | ||
|
|
f1d69da019 | ||
|
|
9d131fdb99 | ||
|
|
29ef6307e0 | ||
|
|
c3c771002f | ||
|
|
554903b818 | ||
|
|
6b71ecd7c6 | ||
|
|
833b8343ac | ||
|
|
8e060890d3 | ||
|
|
abf0fc74e5 | ||
|
|
6242c8c182 | ||
|
|
15327d74d8 | ||
|
|
90c34ca572 | ||
|
|
2b8fa9c7e8 | ||
|
|
2f7aabcfe9 | ||
|
|
ef6d054598 | ||
|
|
201d707aea | ||
|
|
d8b0902ac9 | ||
|
|
01c75bd796 | ||
|
|
0a609306a6 | ||
|
|
5bdc6de862 | ||
|
|
cc01fb9418 | ||
|
|
5e8e1349bc | ||
|
|
ddbb71fb78 | ||
|
|
3289453448 | ||
|
|
611af8ee72 | ||
|
|
69b96a592d | ||
|
|
3151f6a9cc | ||
|
|
726968288c | ||
|
|
ad086f337a | ||
|
|
66d09820c3 | ||
|
|
5ec73e9234 | ||
|
|
17569d0658 | ||
|
|
14ea464c0a | ||
|
|
da9a3c0b12 | ||
|
|
f5a9ffb776 | ||
|
|
19181b79c8 | ||
|
|
1509678578 | ||
|
|
c4d5dd4a3c | ||
|
|
ca734ca547 | ||
|
|
8d347f5162 | ||
|
|
4d368ab6f6 | ||
|
|
cabacdc44b | ||
|
|
71dac2021d | ||
|
|
a8d8d0e340 | ||
|
|
97274d1c80 | ||
|
|
aa12fcbd36 | ||
|
|
3d1c2d1fb6 | ||
|
|
f6fcbdcad0 | ||
|
|
30e6ba0809 | ||
|
|
3cb0fc9f30 | ||
|
|
5f578c3327 | ||
|
|
e56402058a | ||
|
|
19ae21e797 | ||
|
|
b89a31d4ae | ||
|
|
07e06a6ed4 | ||
|
|
89102d6612 | ||
|
|
feda173b3b | ||
|
|
02f812ce96 | ||
|
|
7250bed701 | ||
|
|
2b11b1a234 | ||
|
|
18e8aadf18 | ||
|
|
4840a292df | ||
|
|
76ca1995ca | ||
|
|
0f333b1f24 | ||
|
|
1f8eaa423f | ||
|
|
4e4d3670a2 | ||
|
|
cd9a6a80e3 | ||
|
|
429618d5f8 | ||
|
|
1e814a77cc | ||
|
|
41b0c57725 | ||
|
|
470822f4a9 | ||
|
|
946bf9ec38 | ||
|
|
49585940f7 | ||
|
|
9257d62216 | ||
|
|
3a9d2e7652 | ||
|
|
4b2e3c0ded | ||
|
|
6978df4c3c | ||
|
|
1f13189ec3 | ||
|
|
c08e8081fe | ||
|
|
d8c9b5a936 | ||
|
|
fa823e1296 | ||
|
|
de89ab957b | ||
|
|
96920495d0 | ||
|
|
92044de6b8 | ||
|
|
8e8ef300ec | ||
|
|
982220430e | ||
|
|
3f55c24e36 | ||
|
|
0c65612368 | ||
|
|
3071b7dd37 | ||
|
|
5daac24e73 | ||
|
|
bcd2828305 | ||
|
|
b5f4e9af46 | ||
|
|
92f6a6a16b | ||
|
|
019a20d027 | ||
|
|
1378feaf6a | ||
|
|
22221e6fae | ||
|
|
d773ff5391 | ||
|
|
be84f0cca6 | ||
|
|
1419c16d04 | ||
|
|
89f209014e | ||
|
|
21aec46763 | ||
|
|
333b5e50c4 | ||
|
|
e94550b7b2 | ||
|
|
dcdf8398da | ||
|
|
503f610ff6 | ||
|
|
c875cc858d | ||
|
|
403384944c | ||
|
|
4e73a81d4b | ||
|
|
ed74fb67f2 | ||
|
|
895af548a0 | ||
|
|
a32450ab22 | ||
|
|
a4fd7f8452 | ||
|
|
8214f2a910 | ||
|
|
06d54a9060 | ||
|
|
d801effbce | ||
|
|
6658412f8b | ||
|
|
11cbeb27f8 | ||
|
|
6db9fee823 | ||
|
|
35e2daa807 | ||
|
|
0b61ddda2d | ||
|
|
0045bbd785 | ||
|
|
a10954ed14 | ||
|
|
0bc53b9c2a | ||
|
|
2aeed6c58f | ||
|
|
36a9e27c93 | ||
|
|
36a0094631 | ||
|
|
5d73998f82 | ||
|
|
3cb6f763ca | ||
|
|
a438130784 | ||
|
|
c72d6205e0 | ||
|
|
ce16f81dbf | ||
|
|
c786f58ab8 | ||
|
|
ec19386317 | ||
|
|
5576184f1b | ||
|
|
ebb40664f2 | ||
|
|
e5ad94e620 | ||
|
|
e5934bf94b | ||
|
|
5cfd7e00bb | ||
|
|
649bfefd06 | ||
|
|
0e96b9bfae | ||
|
|
4b87ab6fe9 | ||
|
|
ff2986b3f8 | ||
|
|
8d75537153 | ||
|
|
8ffce2a264 | ||
|
|
083466202e | ||
|
|
7dff245b1a | ||
|
|
272f0795d1 | ||
|
|
22ebb7b8eb | ||
|
|
f6ab720d73 | ||
|
|
a735580540 | ||
|
|
3bcd480345 | ||
|
|
63684ce9a0 | ||
|
|
fc4678ae98 | ||
|
|
f87575958a | ||
|
|
6b051dbf60 | ||
|
|
fb8917cf40 | ||
|
|
8c674fe510 | ||
|
|
f5c4487bb9 | ||
|
|
588cee17fb | ||
|
|
ea1ac92a56 | ||
|
|
12c415e505 | ||
|
|
2c292e3bad | ||
|
|
dc3d3c2971 | ||
|
|
c86b049c92 | ||
|
|
b0ca3ddfab | ||
|
|
59fc84d975 | ||
|
|
13f817d057 | ||
|
|
b50e11e4b4 | ||
|
|
d56d834245 | ||
|
|
06d23ac98e | ||
|
|
6ec5a409d0 | ||
|
|
7fd0a66eac | ||
|
|
52184112b1 | ||
|
|
89473ac4e7 | ||
|
|
2f66afdc87 | ||
|
|
16cbad2028 | ||
|
|
2f49075d89 | ||
|
|
8771f2716a | ||
|
|
4d72a953dd | ||
|
|
b5ea68af5b | ||
|
|
499346e7ba | ||
|
|
81f4a3e148 | ||
|
|
7fd54c4259 | ||
|
|
fda2d384e6 | ||
|
|
270ccf746c | ||
|
|
6b51d1bbaa | ||
|
|
a0e17f0c27 | ||
|
|
70064be7de | ||
|
|
332286f9ce | ||
|
|
7584412fcb | ||
|
|
b3c342b029 | ||
|
|
cf6db1a0ee | ||
|
|
2ac5c9137c | ||
|
|
8bafefe1b8 | ||
|
|
5a42b776ff | ||
|
|
dfe53629bc | ||
|
|
fc6690d047 | ||
|
|
b3904750b8 | ||
|
|
63ea01b219 | ||
|
|
4345f48dcc | ||
|
|
7f8869649f | ||
|
|
06777e8285 | ||
|
|
0907bddf75 | ||
|
|
8cf225a3e7 | ||
|
|
71a931fdee | ||
|
|
aa2109f4bd | ||
|
|
c2edf9651a | ||
|
|
88e5c3e87e | ||
|
|
e2d593327a | ||
|
|
104047f672 | ||
|
|
9c3faf1edf | ||
|
|
1dc5bf21e0 | ||
|
|
8bc4f3de84 | ||
|
|
6af3b32302 | ||
|
|
dd517627c1 | ||
|
|
05d5824131 | ||
|
|
655138c0ea | ||
|
|
7209eecaba | ||
|
|
3d36849508 | ||
|
|
2e42146102 | ||
|
|
ab05f2533f | ||
|
|
53f796aef7 | ||
|
|
09ea72cb2f | ||
|
|
84cc897fb4 | ||
|
|
d3cc2b8011 | ||
|
|
f4d56195c4 | ||
|
|
ce33a71a43 | ||
|
|
90ae8511b5 | ||
|
|
f41d53cf36 | ||
|
|
e16437aeed | ||
|
|
c2d942d3f2 | ||
|
|
cc5a90b3ca | ||
|
|
2942c5a329 | ||
|
|
d214ea8319 | ||
|
|
7a80b13db8 | ||
|
|
d55e179890 | ||
|
|
33563a4cf8 | ||
|
|
fe21523934 | ||
|
|
9840613e0a | ||
|
|
80c3e80dba | ||
|
|
c6ab0700b7 | ||
|
|
6d23773569 | ||
|
|
f5bc16944f | ||
|
|
004b9bd8c4 | ||
|
|
4b7fa5a303 | ||
|
|
9efc9653a7 | ||
|
|
0ff51a82ee | ||
|
|
d874ea4f83 | ||
|
|
5688c97ee0 | ||
|
|
3cbf953ad2 | ||
|
|
4f61e921cd | ||
|
|
f903e6099b | ||
|
|
f3a6c0db6b | ||
|
|
5880078bfc | ||
|
|
6689232f7a | ||
|
|
29cc5be9b3 | ||
|
|
9dd0611ccc | ||
|
|
b0c8017839 | ||
|
|
891828af8e | ||
|
|
32466c758c | ||
|
|
3b11316982 | ||
|
|
b87e5dd023 | ||
|
|
7e5597271b | ||
|
|
3d1334f7d9 | ||
|
|
5f5c2e1a52 | ||
|
|
ce8d273c79 | ||
|
|
64e849a677 | ||
|
|
cad390fa13 | ||
|
|
55bdfd0067 | ||
|
|
8a164c416f | ||
|
|
54ad6b65c2 | ||
|
|
b0d5dd28e0 | ||
|
|
ec86e40a8d | ||
|
|
4abcee00e1 | ||
|
|
91c8624d80 | ||
|
|
f6dc3bf8df | ||
|
|
a9b8d1d06f | ||
|
|
76b61d0135 | ||
|
|
d7409832ab | ||
|
|
1749f95086 | ||
|
|
2e11e64881 | ||
|
|
db86f3275b | ||
|
|
62c09ea142 | ||
|
|
589bd2c162 | ||
|
|
4c7ed8ea0d | ||
|
|
25021d1b20 | ||
|
|
53fdfdf8db | ||
|
|
8a45f2de4d | ||
|
|
4ccc7bae40 | ||
|
|
2585c47de8 | ||
|
|
b49798950a | ||
|
|
03f15f91b3 | ||
|
|
8e13d9c3ac | ||
|
|
80932eb80c | ||
|
|
b2ae2c05d8 | ||
|
|
e349fc9bd1 | ||
|
|
53213dfb26 | ||
|
|
40f27a53b6 | ||
|
|
2d900600bf | ||
|
|
047e9c248f | ||
|
|
1cc1c39903 | ||
|
|
f6f2a29a7a | ||
|
|
4368f6a887 | ||
|
|
f6db9fb387 | ||
|
|
afa3804bea | ||
|
|
e2f43b4931 | ||
|
|
067fdd0921 | ||
|
|
1296e6bd45 | ||
|
|
d883edb51f | ||
|
|
51fcf13d37 | ||
|
|
91ef87b4e0 | ||
|
|
4c0cc947a2 | ||
|
|
6fcb7a4845 | ||
|
|
213f96b189 | ||
|
|
e8d6d4ad3a | ||
|
|
87370cfdf0 | ||
|
|
112a644927 | ||
|
|
2977e3528a | ||
|
|
fd7b5e0dd4 | ||
|
|
21fd079f94 | ||
|
|
9bdc61b50d | ||
|
|
2de24d5b55 | ||
|
|
1cba9d0fb9 | ||
|
|
8f532bf4a6 | ||
|
|
4efdabd475 | ||
|
|
8803101dcd | ||
|
|
a4a9381b65 | ||
|
|
618d42019e | ||
|
|
d9b56cfb5b | ||
|
|
5624dc4f83 | ||
|
|
313fe47050 | ||
|
|
772332661f | ||
|
|
4c6b512455 | ||
|
|
cc576f5220 | ||
|
|
b193329c23 | ||
|
|
8e1459f092 | ||
|
|
e19b55ff65 | ||
|
|
687161df98 | ||
|
|
c54cfebf5f | ||
|
|
e8d0ed1370 | ||
|
|
87a17a5196 | ||
|
|
d19df3389c | ||
|
|
faa4c606e5 | ||
|
|
210fbd18ac | ||
|
|
3ba0141ab8 | ||
|
|
279a2ddaeb | ||
|
|
ea7ee3dd7a | ||
|
|
a55bea1e07 | ||
|
|
ed5cb7f17b | ||
|
|
c7f8daaff3 | ||
|
|
ac48ad78db | ||
|
|
c18e18cce8 | ||
|
|
65ba13e503 | ||
|
|
44ec9e1d43 | ||
|
|
7c53949741 | ||
|
|
6d37769e38 | ||
|
|
c525f8d2cc | ||
|
|
04dfcae898 | ||
|
|
9b5a99a8ca | ||
|
|
c44f1c5282 | ||
|
|
24dfa99034 | ||
|
|
35c0177729 | ||
|
|
098d67cd8c | ||
|
|
d8e0ced54c | ||
|
|
8e9b409bae | ||
|
|
b995a6b6c0 | ||
|
|
3e76a52306 | ||
|
|
4e91a2e2ef | ||
|
|
4314912d5a | ||
|
|
93da5157ff | ||
|
|
cc7bc4ffb8 | ||
|
|
78eefe9da1 | ||
|
|
7f7c4acdcb | ||
|
|
a23dbfa3df | ||
|
|
249104e7a3 | ||
|
|
76bbe7de6b | ||
|
|
1994dde1f2 | ||
|
|
a8626e400d | ||
|
|
f5136da681 | ||
|
|
8efc243e43 | ||
|
|
cd21860535 | ||
|
|
7054623678 | ||
|
|
37781d51f3 | ||
|
|
ec4e43d4fc | ||
|
|
1d4529128f | ||
|
|
60ed3e2a8d | ||
|
|
5b67e7c0b4 | ||
|
|
1399d3c44b | ||
|
|
eb543cf32d | ||
|
|
9c6d9833d6 | ||
|
|
f50cbd71b7 | ||
|
|
3d5b3db321 | ||
|
|
e438602773 | ||
|
|
f42b29e4ac | ||
|
|
92995d9c2b | ||
|
|
b21f1648c4 | ||
|
|
94c04b44df | ||
|
|
54ed0678bf | ||
|
|
817ec96963 | ||
|
|
8ad5458e2a | ||
|
|
a87c5edd60 | ||
|
|
e4327736bd | ||
|
|
1fddbe5f92 | ||
|
|
2c482e7df8 | ||
|
|
c8edcd3dad | ||
|
|
a82c25c7cb | ||
|
|
73b423030f | ||
|
|
809551d0f8 | ||
|
|
e795ec64d6 | ||
|
|
f8373e4798 | ||
|
|
a31c434fbc | ||
|
|
1fa8aae80a | ||
|
|
27b60a4df9 | ||
|
|
d21983aab4 | ||
|
|
9406d78653 | ||
|
|
c7ae0e94d7 | ||
|
|
308a8e3ae3 | ||
|
|
4c90cc1838 | ||
|
|
460a40711e | ||
|
|
18cf814779 | ||
|
|
a9656afbbf | ||
|
|
385fe008ce | ||
|
|
7e25e853d7 | ||
|
|
de5adbe3d2 | ||
|
|
c239ae3f49 | ||
|
|
1bfdff5b30 | ||
|
|
63db927924 | ||
|
|
9a34e4af27 | ||
|
|
b5589338ec | ||
|
|
a19059aa76 | ||
|
|
15f962310b | ||
|
|
a32def2086 | ||
|
|
21af624096 | ||
|
|
227870ac78 | ||
|
|
33cb3dbd6a | ||
|
|
eb7813ac6f | ||
|
|
0f8f2fe560 | ||
|
|
51f38f0884 | ||
|
|
6b93fd179d | ||
|
|
9385f36832 | ||
|
|
a5dc15dc08 | ||
|
|
eab996f7e7 | ||
|
|
43c5a33773 | ||
|
|
7fb0226ddc | ||
|
|
db6e7a7480 | ||
|
|
6335ad1571 |
@@ -1,62 +1,32 @@
|
||||
# Compiled Output
|
||||
dist
|
||||
tmp
|
||||
/out-tsc
|
||||
|
||||
# Project Dependencies
|
||||
# VCS and editor state
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
|
||||
# Docker
|
||||
compose*.yml
|
||||
Dockerfile
|
||||
|
||||
# IDEs and Editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vs/*
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Miscellaneous
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.cursor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.editorconfig
|
||||
.eslint*
|
||||
.vite-hooks
|
||||
|
||||
# Generated Files
|
||||
.nx
|
||||
.swc
|
||||
fly.toml
|
||||
stats.html
|
||||
tools/compose/*
|
||||
tools/scripts/*
|
||||
# Local configuration and runtime state
|
||||
.env*
|
||||
/data
|
||||
|
||||
# Environment Variables
|
||||
*.env*
|
||||
!.env.example
|
||||
# Dependency and package-manager caches
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Lingui Compiled Messages
|
||||
apps/client/src/locales/_build/
|
||||
apps/client/src/locales/*/messages.mjs
|
||||
# Build, test, and framework output
|
||||
dist
|
||||
**/dist
|
||||
.nitro
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.tanstack
|
||||
.turbo
|
||||
**/.turbo
|
||||
coverage
|
||||
reports
|
||||
|
||||
# Generated service-worker assets are rebuilt during the web build.
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,70 +1,117 @@
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
# --- Application ---
|
||||
# Public port used by the production server and the Vite web server in local development.
|
||||
PORT="3000"
|
||||
|
||||
# Ports
|
||||
PORT=3000
|
||||
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
||||
SERVER_PORT="3001"
|
||||
|
||||
# URLs
|
||||
# These URLs must reference a publicly accessible domain or IP address, not a docker container ID (depending on your compose setup)
|
||||
PUBLIC_URL=http://localhost:3000
|
||||
STORAGE_URL=http://localhost:9000/default # default is the bucket name specified in the STORAGE_BUCKET variable
|
||||
# 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"
|
||||
|
||||
# Database (Prisma/PostgreSQL)
|
||||
# This can be swapped out to use any other database, like MySQL
|
||||
# Note: This is used only in the compose.yml file
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
# --- Database (PostgreSQL) ---
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# Database (Prisma/PostgreSQL)
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
|
||||
|
||||
# Authentication Secrets
|
||||
# generated with `openssl rand -base64 64`
|
||||
ACCESS_TOKEN_SECRET=access_token_secret
|
||||
REFRESH_TOKEN_SECRET=refresh_token_secret
|
||||
# Better Auth Dashboard (optional)
|
||||
# Enables the Better Auth Dashboard plugin when set, you probably don't need this.
|
||||
BETTER_AUTH_API_KEY=""
|
||||
|
||||
# Chrome Browser (for printing)
|
||||
# generated with `openssl rand -hex 32`
|
||||
CHROME_PORT=8080
|
||||
CHROME_TOKEN=chrome_token
|
||||
CHROME_URL=ws://localhost:8080
|
||||
# Launch puppeteer with flag to ignore https errors
|
||||
# CHROME_IGNORE_HTTPS_ERRORS=true
|
||||
# Social Auth (Google, optional)
|
||||
# Set both values to enable Google sign-in.
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Mail Server (for e-mails)
|
||||
# For testing, you can use https://ethereal.email/create
|
||||
MAIL_FROM=noreply@localhost
|
||||
# SMTP_URL=smtp://username:password@smtp.ethereal.email:587
|
||||
# Social Auth (GitHub, optional)
|
||||
# Set both values to enable GitHub sign-in.
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Storage
|
||||
STORAGE_ENDPOINT=localhost
|
||||
STORAGE_PORT=9000
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_BUCKET=default
|
||||
STORAGE_ACCESS_KEY=minioadmin
|
||||
STORAGE_SECRET_KEY=minioadmin
|
||||
STORAGE_USE_SSL=false
|
||||
STORAGE_SKIP_BUCKET_CHECK=false
|
||||
# Social Auth (LinkedIn, optional)
|
||||
# Set both values to enable LinkedIn sign-in.
|
||||
LINKEDIN_CLIENT_ID=""
|
||||
LINKEDIN_CLIENT_SECRET=""
|
||||
|
||||
# Nx Cloud (Optional)
|
||||
# NX_CLOUD_ACCESS_TOKEN=
|
||||
# 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_TOKEN_URL=""
|
||||
OAUTH_USER_INFO_URL=""
|
||||
|
||||
# Crowdin (Optional)
|
||||
# CROWDIN_PROJECT_ID=
|
||||
# CROWDIN_PERSONAL_TOKEN=
|
||||
# Space-separated scopes requested from the custom OAuth provider.
|
||||
OAUTH_SCOPES="openid profile email"
|
||||
|
||||
# Feature Flags (Optional)
|
||||
# DISABLE_SIGNUPS=false
|
||||
# DISABLE_EMAIL_AUTH=false
|
||||
# --- Email (optional) ---
|
||||
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
||||
# email to the console instead.
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# GitHub (OAuth, Optional)
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# GITHUB_CALLBACK_URL=http://localhost:5173/api/auth/github/callback
|
||||
# --- Storage (optional) ---
|
||||
# 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"
|
||||
|
||||
# Google (OAuth, Optional)
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GOOGLE_CALLBACK_URL=http://localhost:5173/api/auth/google/callback
|
||||
# Seaweedfs
|
||||
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"
|
||||
|
||||
# --- 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.
|
||||
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"
|
||||
|
||||
# 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)
|
||||
# For font-list generation tooling.
|
||||
# Requires "Google Fonts Developer API" to be enabled.
|
||||
GOOGLE_CLOUD_API_KEY=""
|
||||
|
||||
# Crowdin (optional)
|
||||
# For translation tooling.
|
||||
CROWDIN_PROJECT_ID=""
|
||||
CROWDIN_API_TOKEN=""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["**/*"],
|
||||
"plugins": ["@nx"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"extends": ["plugin:prettier/recommended"],
|
||||
"plugins": ["simple-import-sort", "unused-imports"],
|
||||
"rules": {
|
||||
// eslint
|
||||
"no-console": "error",
|
||||
"no-return-await": "off",
|
||||
|
||||
// simple-import-sort
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
|
||||
// unused-imports
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
|
||||
// nx
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
"allowCircularSelfDependency": true,
|
||||
"enforceBuildableLibDependency": true,
|
||||
"allow": [],
|
||||
"depConstraints": [
|
||||
{
|
||||
"sourceTag": "*",
|
||||
"onlyDependOnLibsWithTags": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// prettier
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": ["tsconfig.*?.json"]
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@nx/typescript",
|
||||
"plugin:@typescript-eslint/strict-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
"plugin:unicorn/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint", "unicorn"],
|
||||
"rules": {
|
||||
// typescript-eslint
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/return-await": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-redundant-type-constituents": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
|
||||
|
||||
// unicorn
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/prefer-string-replace-all": "off",
|
||||
"unicorn/prefer-structured-clone": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"extends": ["plugin:@nx/javascript"],
|
||||
"rules": {
|
||||
// eslint
|
||||
"no-console": "warn",
|
||||
"no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name: 🐞 Bug Report
|
||||
description: Create a bug report to help improve Reactive Resume
|
||||
|
||||
title: "[Bug] <title>"
|
||||
labels: [bug, v4, needs triage]
|
||||
labels: [bug, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
|
||||
body:
|
||||
@@ -28,43 +28,11 @@ body:
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
label: Describe the bug you're experiencing
|
||||
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Detailed steps to reproduce the behavior, so that it can be easily diagnosed.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: template
|
||||
attributes:
|
||||
@@ -76,14 +44,16 @@ body:
|
||||
- Bronzor
|
||||
- Chikorita
|
||||
- Ditto
|
||||
- Ditgar
|
||||
- Gengar
|
||||
- Glalie
|
||||
- Kakuna
|
||||
- Lapras
|
||||
- Leafish
|
||||
- Nosepass
|
||||
- Onyx
|
||||
- Pikachu
|
||||
- Rhyhorn
|
||||
- Scizor
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -3,7 +3,7 @@ name: ✨ Feature Request
|
||||
description: Suggest an feature or idea that you would like to see in Reactive Resume
|
||||
|
||||
title: "[Feature] <title>"
|
||||
labels: [enhancement, v4, needs triage]
|
||||
labels: [enhancement, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
|
||||
body:
|
||||
@@ -18,6 +18,6 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: A concise description of what feature you would like to see in Reactive Resume.
|
||||
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,41 @@
|
||||
name: autofix.ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Remove Unused Dependencies
|
||||
run: pnpm knip --fix
|
||||
|
||||
- name: Lint and Format
|
||||
run: pnpm check
|
||||
|
||||
- name: Autofix
|
||||
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync Translations from Crowdin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Sync Translations from Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
download_translations: true
|
||||
localization_branch_name: "l10n"
|
||||
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
||||
pull_request_title: "Sync Translations from Crowdin"
|
||||
pull_request_body: "This is an automated pull request to sync translations from [Crowdin](https://crowdin.com/project/reactive-resume)."
|
||||
pull_request_labels: "l10n"
|
||||
pull_request_reviewers: "amruthpillai"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -0,0 +1,251 @@
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
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: ${{ fromJSON(needs.mode.outputs.matrix) }}
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
|
||||
|
||||
- name: Build and Push by Digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
sbom: true
|
||||
push: true
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs:
|
||||
- mode
|
||||
- build
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: package.json
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Parse version components
|
||||
id: semver
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
|
||||
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
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" \
|
||||
--annotation "index:org.opencontainers.image.title=Reactive Resume" \
|
||||
--annotation "index:org.opencontainers.image.description=A free and open-source resume builder." \
|
||||
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
|
||||
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
|
||||
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
|
||||
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
|
||||
$(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 }}:${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"
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign images with Cosign
|
||||
run: |
|
||||
# Sign GHCR image
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
|
||||
|
||||
# Sign Docker Hub image
|
||||
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
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 }}
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
script: |
|
||||
cd docker
|
||||
./manage_stack.sh up reactive_resume
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Lint, Test & Build
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version: 22
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Format
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Test
|
||||
run: pnpm run test
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
@@ -1,167 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: amruthpillai/reactive-resume
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.1.4
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: version
|
||||
run: echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registery
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.3.0
|
||||
|
||||
- name: Extract Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
with:
|
||||
tags: type=semver,pattern={{version}},prefix=v,value=${{ steps.version.outputs.version }}
|
||||
images: |
|
||||
${{ env.IMAGE }}
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
|
||||
- name: Prepare a unique name for Artifacts
|
||||
id: artifact_name
|
||||
run: |
|
||||
name=$(echo -n "${{ matrix.platform }}" | sed -e 's/[ \t:\/\\"<>|*?]/-/g' -e 's/--*/-/g')
|
||||
echo "name=$name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and Push by Digest
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
|
||||
- name: Export Digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload Digest
|
||||
uses: actions/upload-artifact@v4.3.3
|
||||
with:
|
||||
name: digests-${{ steps.artifact_name.outputs.name }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs:
|
||||
- build
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.1.4
|
||||
|
||||
- name: Download Digest
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registery
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.3.0
|
||||
|
||||
- name: Extract Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
with:
|
||||
tags: type=semver,pattern={{version}},prefix=v,value=${{ needs.build.outputs.version }}
|
||||
images: |
|
||||
${{ env.IMAGE }}
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
|
||||
- name: Create Docker Manifest List and Push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect Image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Update Repository Description
|
||||
uses: peter-evans/dockerhub-description@v4.0.0
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Deploy the latest image on rxresu.me
|
||||
run: curl -Xk POST ${{ secrets.SERVICE_WEBHOOK }}
|
||||
|
||||
- name: Inform about the release on Discord
|
||||
uses: sarisia/actions-status-discord@v1.14.3
|
||||
if: always()
|
||||
with:
|
||||
username: ReleaseBot
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ job.status }}
|
||||
title: "Release `${{ steps.meta.outputs.version }}`"
|
||||
description: "A new version of Reactive Resume just dropped! 🚀"
|
||||
url: "https://github.com/AmruthPillai/Reactive-Resume"
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Sync Crowdin Translations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: 0 0 * * * # everyday at midnight (UTC)
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'AmruthPillai/Reactive-Resume'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Sync Translations
|
||||
uses: crowdin/github-action@v1.15.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
create_pull_request: true
|
||||
localization_branch_name: "l10n"
|
||||
pull_request_base_branch_name: "main"
|
||||
pull_request_title: "New Translations from Crowdin"
|
||||
pull_request_body: "You've got new translations to be merged into the app from contributors on Crowdin.\n\n_This pull request was automatically created by the [Crowdin Action](https://github.com/marketplace/actions/crowdin-action)._"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -1,52 +1,58 @@
|
||||
# Compiled Output
|
||||
dist
|
||||
tmp
|
||||
/out-tsc
|
||||
|
||||
# Project Dependencies
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build Outputs
|
||||
dist
|
||||
.vercel
|
||||
.wrangler
|
||||
|
||||
# Environment Variables
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# IDEs and Editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vs/*
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Miscellaneous
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
# Log Files
|
||||
logs
|
||||
*.log*
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Turborepo
|
||||
.turbo
|
||||
|
||||
# Generated Files
|
||||
.nx
|
||||
.swc
|
||||
fly.toml
|
||||
stats.html
|
||||
# TanStack Start
|
||||
.tanstack
|
||||
|
||||
# Environment Variables
|
||||
*.env*
|
||||
!.env.example
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
|
||||
# Lingui Compiled Messages
|
||||
apps/client/src/locales/_build/
|
||||
apps/client/src/locales/*/messages.*js
|
||||
# Cache
|
||||
tmp
|
||||
temp
|
||||
.cache
|
||||
|
||||
# AI
|
||||
.codex
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
const betaPackages = ["drizzle-zod"];
|
||||
const rcPackages = ["drizzle-orm", "drizzle-kit"];
|
||||
|
||||
/** @type {import('npm-check-updates').RunOptions} */
|
||||
module.exports = {
|
||||
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,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/raineorshine/npm-check-updates/main/src/types/RunOptions.json",
|
||||
"upgrade": true,
|
||||
"target": "minor",
|
||||
"install": "always",
|
||||
"packageManager": "pnpm",
|
||||
"reject": [
|
||||
"eslint",
|
||||
"@swc/*",
|
||||
"@swc-node/*",
|
||||
"@reactive-resume/*",
|
||||
"eslint-plugin-unused-imports"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
auto-install-peers=true
|
||||
enable-pre-post-scripts=true
|
||||
strict-peer-dependencies=false
|
||||
@@ -1,7 +0,0 @@
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
stats.html
|
||||
pnpm-lock.yaml
|
||||
compose-dev.yml
|
||||
compose.yml
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["cn", "cva"]
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["nrwl.angular-console", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Overview
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **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.
|
||||
|
||||
### Package and feature boundaries
|
||||
|
||||
- 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.
|
||||
|
||||
Placement decision tree:
|
||||
|
||||
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.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL runs via Docker Compose:
|
||||
|
||||
```
|
||||
sudo docker compose -f compose.dev.yml up -d postgres
|
||||
```
|
||||
|
||||
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. The three required variables are:
|
||||
|
||||
- `APP_URL` (default `http://localhost:3000`)
|
||||
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
|
||||
- `AUTH_SECRET` (any non-empty string)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Common commands
|
||||
|
||||
| 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` |
|
||||
|
||||
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||
|
||||
```
|
||||
pnpm --filter web typecheck
|
||||
pnpm --filter @reactive-resume/pdf test
|
||||
pnpm --filter @reactive-resume/api test
|
||||
pnpm exec turbo boundaries
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
### What’s changed from v3 to v4?
|
||||
|
||||
**The entire app has been rebuilt and reimagined from the ground up.**
|
||||
|
||||
The **user interface** has been greatly streamlined to prioritise your content and resume. The design of templates has also undergone a major overhaul. Previously, we utilised TailwindCSS for creating templates, but now you can rely on CSS (styled-components) to build any design you prefer. With this change, I hope to offer a **much wider variety of templates** compared to the previous version.
|
||||
|
||||
When it comes to features, there are many to mention, but some highlights include the **ability to use your own OpenAI API key** (stored on your browser) and leverage GPTs to enhance your resume writing skills. With this, you can improve your writing, correct spelling and grammar, and even adjust the tone of the text to be more confident or casual.
|
||||
|
||||
When you make your resume publicly available, you are provided with a link that you can share with potential recruiters and employers. This change allows you to **track the number of views or downloads your resume has received**, so you can stay informed about when someone has checked out your resume.
|
||||
|
||||
When it comes to **security**, you now have the option to protect your account with **two-factor authentication**. This means that whenever you log in to Reactive Resume, you will also need to enter a one-time code generated on your phone. This additional step ensures that only you have access to your account.
|
||||
|
||||
From a **design** perspective, the motivation behind this is to ensure that Reactive Resume is taken more seriously and not perceived as just another subpar side-project, which is often associated with free software. My goal is to demonstrate that this is not the case, and that **free and open source software can be just as good**, if not better, than paid alternatives.
|
||||
|
||||
From a **self-hosting perspective**, it has never been simpler. Instead of running two separate services on your Docker (one for the client and one for the server) and struggling to establish communication between them, now you only need to pull a single image. Additionally, there are a few dependent services available on Docker (such as Postgres, Minio etc.) that you can also pull and have them all working together seamlessly.
|
||||
|
||||
I'm excited for you to try out the app, as I've spent months building it to perfection. If you enjoy the experience of building your resume using the app, please consider supporting by [becoming a GitHub Sponsor](https://github.com/sponsors/AmruthPillai).
|
||||
@@ -1,132 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[INSERT CONTACT METHOD].
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -1,85 +0,0 @@
|
||||
# Contributing to Reactive Resume
|
||||
|
||||
## Getting the project set up locally
|
||||
|
||||
There are a number of Docker Compose examples that are suitable for a wide variety of deployment strategies depending on your use-case. All the examples can be found in the `tools/compose` folder.
|
||||
|
||||
To run the development environment of the application locally on your computer, please follow these steps:
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Docker (with Docker Compose)
|
||||
- Node.js 20 or higher (with pnpm)
|
||||
|
||||
### 1. Fork and Clone the Repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/{your-github-username}/Reactive-Resume.git
|
||||
cd Reactive-Resume
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. Copy .env.example to .env
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Please have a brief look over the environment variables and change them if necessary, for example, change the ports if you have a conflicting service running on your machine already.
|
||||
|
||||
### 4. Fire up all the required services through Docker Compose
|
||||
|
||||
```sh
|
||||
docker compose -f tools/compose/development.yml --env-file .env -p reactive-resume up -d
|
||||
```
|
||||
|
||||
It should take just under half a minute for all the services to be booted up correctly. You can check the status of all services by running `docker compose -p reactive-resume ps`
|
||||
|
||||
### 5. Run the development server
|
||||
|
||||
```sh
|
||||
pnpm prisma:migrate:dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If everything went well, the frontend should be running on `http://localhost:5173` and the backend api should be accessible through `http://localhost:3000`. There is a proxy present to also route all requests to `http://localhost:5173/api` directly to the API. If you need to change the `PORT` environment variable for the server, please make sure to update the `apps/client/proxy.conf.json` file as well with the new endpoint.
|
||||
|
||||
You can also visit `http://localhost:3000/api/health`, the health check endpoint of the server to check if the server is running correctly, and it is able to connect to all it's dependent services. The output of the health check endpoint should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"info": {
|
||||
"database": { "status": "up" },
|
||||
"storage": { "status": "up" },
|
||||
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
|
||||
},
|
||||
"error": {},
|
||||
"details": {
|
||||
"database": { "status": "up" },
|
||||
"storage": { "status": "up" },
|
||||
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pushing changes to the app
|
||||
|
||||
Firstly, ensure that there is a GitHub Issue created for the feature or bugfix you are working on. If it does not exist, create an issue and assign it to yourself.
|
||||
|
||||
Once you are happy with the changes you've made locally, commit it to your repository. Note that the project makes use of Conventional Commits, so commit messages would have to be in a specific format for it to be accepted. For example, a commit message to fix the translation on the homepage could look like:
|
||||
|
||||
```
|
||||
git commit -m "fix(homepage): fix typo on homepage in the faq section"
|
||||
```
|
||||
|
||||
It helps to be as descriptive as possible in commit messages so that users can be aware of the changes made by you.
|
||||
|
||||
Finally, create a pull request to merge the changes on your forked repository to the original repository hosted on AmruthPillai/Reactive-Resume. I can take a look at the changes you've made when I have the time and have it merged onto the app.
|
||||
@@ -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`).
|
||||
@@ -1,47 +1,76 @@
|
||||
ARG NX_CLOUD_ACCESS_TOKEN
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- Base Image ---
|
||||
FROM node:lts-bullseye-slim AS base
|
||||
ARG NX_CLOUD_ACCESS_TOKEN
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm --activate
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --- Build Image ---
|
||||
FROM base AS build
|
||||
ARG NX_CLOUD_ACCESS_TOKEN
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
||||
COPY ./tools/prisma /app/tools/prisma
|
||||
RUN pnpm install --frozen-lockfile
|
||||
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
|
||||
|
||||
ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN
|
||||
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
|
||||
|
||||
RUN pnpm run build
|
||||
COPY --from=pruner /app/out/full/ ./
|
||||
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
|
||||
|
||||
# --- Release Image ---
|
||||
FROM base AS release
|
||||
ARG NX_CLOUD_ACCESS_TOKEN
|
||||
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
|
||||
|
||||
RUN apt update && apt install -y dumb-init --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
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
|
||||
|
||||
COPY --chown=node:node --from=build /app/.npmrc /app/package.json /app/pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
FROM node:${NODE_VERSION}-slim AS runtime
|
||||
|
||||
COPY --chown=node:node --from=build /app/dist ./dist
|
||||
COPY --chown=node:node --from=build /app/tools/prisma ./tools/prisma
|
||||
RUN pnpm run prisma:generate
|
||||
LABEL maintainer="amruthpillai"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.title="Reactive Resume"
|
||||
LABEL org.opencontainers.image.description="A free and open-source resume builder."
|
||||
LABEL org.opencontainers.image.vendor="Amruth Pillai"
|
||||
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"
|
||||
|
||||
ENV TZ=UTC
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV="production" \
|
||||
PORT=3000 \
|
||||
LOCAL_STORAGE_PATH=/app/data
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /app
|
||||
|
||||
CMD [ "dumb-init", "pnpm", "run", "start" ]
|
||||
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
|
||||
|
||||
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 ["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));"]
|
||||
|
||||
CMD ["node", "apps/server/dist/index.mjs"]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
NODE_ENV=development \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
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 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Amruth Pillai
|
||||
Copyright (c) 2026 Amruth Pillai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -1,99 +1,251 @@
|
||||

|
||||
<div align="center">
|
||||
<a href="https://rxresu.me">
|
||||
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
|
||||
</a>
|
||||
|
||||

|
||||
[](https://hub.docker.com/repository/docker/amruthpillai/reactive-resume)
|
||||
[](https://github.com/sponsors/AmruthPillai)
|
||||
[](https://crowdin.com/project/reactive-resume)
|
||||
[](https://discord.gg/hzwkZbyvUW)
|
||||
<h1>Reactive Resume</h1>
|
||||
|
||||
# Reactive Resume
|
||||
<p>Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.</p>
|
||||
|
||||
A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.
|
||||
<p>
|
||||
<a href="https://rxresu.me"><strong>Get Started</strong></a>
|
||||
·
|
||||
<a href="https://docs.rxresu.me"><strong>Learn More</strong></a>
|
||||
</p>
|
||||
|
||||
### [Go to App](https://rxresu.me/) | [Docs](https://docs.rxresu.me/)
|
||||
<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/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/donate"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Description
|
||||
---
|
||||
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume. With zero user tracking or advertising, your privacy is a top priority. The platform is extremely user-friendly and can be self-hosted in less than 30 seconds if you wish to own your data completely.
|
||||
Reactive Resume makes building resumes straightforward. Pick a template, fill in your details, and export to PDF—no account required for basic use. For those who want more control, the entire application can be self-hosted on your own infrastructure.
|
||||
|
||||
It's available in multiple languages and comes packed with features such as real-time editing, dozens of templates, drag-and-drop customisation, and integration with OpenAI for enhancing your writing.
|
||||
|
||||
You can share a personalised link of your resume to potential employers, track its views or downloads, and customise your page layout by dragging-and-dropping sections. The platform also supports various font options and provides dozens of templates to choose from. And yes, there's even a dark mode for a more comfortable viewing experience.
|
||||
|
||||
Start creating your standout resume with Reactive Resume today!
|
||||
|
||||
## Templates
|
||||
|
||||
| Azurill | Bronzor | Chikorita |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| <img src="https://i.imgur.com/jKgo04C.jpeg" width="200px" /> | <img src="https://i.imgur.com/DFNQZP2.jpg" width="200px" /> | <img src="https://i.imgur.com/Dwv8Y7f.jpg" width="200px" /> |
|
||||
|
||||
| Ditto | Kakuna | Nosepass |
|
||||
| ----------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| <img src="https://i.imgur.com/6c5lASL.jpg" width="200px" /> | <img src="https://i.imgur.com/268ML3t.jpg" width="200px" /> | <img src="https://i.imgur.com/npRLsPS.jpg" width="200px" /> |
|
||||
|
||||
| Onyx | Pikachu | Rhyhorn |
|
||||
| ----------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| <img src="https://i.imgur.com/cxplXOW.jpg" width="200px" /> | <img src="https://i.imgur.com/Y9f7qsh.jpg" width="200px" /> | <img src="https://i.imgur.com/h4kQxy2.jpg" width="200px" /> |
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Free, forever** and open-source
|
||||
- No telemetry, user tracking or advertising
|
||||
- You can self-host the application in less than 30 seconds
|
||||
- **Available in multiple languages** ([help add/improve your language here](https://translate.rxresu.me/))
|
||||
- Use your email address (or a throw-away address, no problem) to create an account
|
||||
- You can also sign in with your GitHub or Google account, and even set up two-factor authentication for extra security
|
||||
- Create as many resumes as you like under a single account, optimising each resume for every job application based on its description for a higher ATS score
|
||||
- **Bring your own OpenAI API key** and unlock features such as improving your writing, fixing spelling and grammar or changing the tone of your text in one-click
|
||||
- Translate your resume into any language using ChatGPT and import it back for easier editing
|
||||
- Create single page resumes or a resume that spans multiple pages easily
|
||||
- Customize the colours and layouts to add a personal touch to your resume
|
||||
- Customise your page layout as you like just by dragging-and-dropping sections
|
||||
- Create custom sections that are specific to your industry if the existing ones don't fit
|
||||
- Jot down personal notes specific to your resume that's only visible to you
|
||||
- Lock a resume to prevent making any further edits (useful for master templates)
|
||||
- **Dozens of templates** to choose from, ranging from professional to modern
|
||||
- Design your resume using the standardised EuroPass design template
|
||||
- Supports printing resumes in A4 or Letter page formats
|
||||
- Design your resume with any font that's available on [Google Fonts](https://fonts.google.com/)
|
||||
- **Share a personalised link of your resume** to companies or recruiters for them to get the latest updates
|
||||
- You can track the number of views or downloads your public resume has received
|
||||
- Built with state-of-the-art (at the moment) and dependable technologies that's battle tested and peer reviewed by the open-source community on GitHub
|
||||
- **MIT License**, so do what you like with the code as long as you credit the original author
|
||||
- And yes, there’s a dark mode too 🌓
|
||||
**Resume Building**
|
||||
|
||||
## Built With
|
||||
- Real-time preview as you type
|
||||
- Multiple export formats (PDF, JSON, DOCX)
|
||||
- Drag-and-drop section ordering
|
||||
- Custom sections for any content type
|
||||
- Rich text editor with formatting support
|
||||
|
||||
- React (Vite), for the frontend
|
||||
- NestJS, for the backend
|
||||
- Postgres (primary database)
|
||||
- Prisma ORM, which frees you to switch to any other relational database with a few minor changes in the code
|
||||
- Minio (for object storage: to store avatars, resume PDFs and previews)
|
||||
- Browserless (for headless chrome, to print PDFs and generate previews)
|
||||
- SMTP Server (to send password recovery emails)
|
||||
- GitHub/Google OAuth (for quickly authenticating users)
|
||||
- LinguiJS and Crowdin (for translation management and localization)
|
||||
**Templates**
|
||||
|
||||
- Professionally designed templates
|
||||
- A4 and Letter size support
|
||||
- Customizable colors, fonts, and spacing
|
||||
- Custom CSS for advanced styling
|
||||
|
||||
**Privacy & Control**
|
||||
|
||||
- Self-host on your own infrastructure
|
||||
- No tracking or analytics by default
|
||||
- Full data export at any time
|
||||
- Delete your data permanently with one click
|
||||
|
||||
**Extras**
|
||||
|
||||
- AI integration (OpenAI, Google Gemini, Anthropic Claude)
|
||||
- Multi-language support
|
||||
- Share resumes via unique links
|
||||
- Import from JSON Resume format
|
||||
- Dark mode support
|
||||
- Passkey and two-factor authentication
|
||||
|
||||
## Templates
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<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="apps/web/public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
|
||||
<br /><sub><b>Bronzor</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<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="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="apps/web/public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
|
||||
<br /><sub><b>Gengar</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<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="apps/web/public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
|
||||
<br /><sub><b>Kakuna</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<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="apps/web/public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
|
||||
<br /><sub><b>Leafish</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<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="apps/web/public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
|
||||
<br /><sub><b>Pikachu</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<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="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>
|
||||
|
||||
## Quick Start
|
||||
|
||||
The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||
cd reactive-resume
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# Access the app
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
[](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
|
||||
|
||||
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
| ---------------- | ------------------------------- |
|
||||
| Framework | TanStack Start (React 19, Vite) |
|
||||
| Runtime | Node.js |
|
||||
| Language | TypeScript |
|
||||
| Database | PostgreSQL with Drizzle ORM |
|
||||
| API | ORPC (Type-safe RPC) |
|
||||
| Auth | Better Auth |
|
||||
| Styling | Tailwind CSS |
|
||||
| UI Components | Base UI + shadcn-style package |
|
||||
| State Management | Zustand + TanStack Query |
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
|
||||
| Guide | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------- |
|
||||
| [Getting Started](https://docs.rxresu.me/getting-started) | First-time setup and basic usage |
|
||||
| [Self-Hosting](https://docs.rxresu.me/self-hosting/docker) | Deploy on your own server |
|
||||
| [Development Setup](https://docs.rxresu.me/contributing/development) | Local development environment |
|
||||
| [Project Architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
|
||||
| [Exporting Your Resume](https://docs.rxresu.me/guides/exporting-your-resume) | PDF and JSON export options |
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
Reactive Resume can be self-hosted using Docker. The stack includes:
|
||||
|
||||
- **PostgreSQL** — Database for storing user data and resumes
|
||||
- **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
|
||||
# Docker Hub
|
||||
docker pull amruthpillai/reactive-resume:latest
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/amruthpillai/reactive-resume:latest
|
||||
```
|
||||
|
||||
See the [self-hosting guide](https://docs.rxresu.me/self-hosting/docker) for complete instructions.
|
||||
|
||||
## Support
|
||||
|
||||
Reactive Resume is and always will be free and open-source. If it has helped you land a job or saved you time, please consider supporting continued development:
|
||||
|
||||
<p>
|
||||
<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/donate">
|
||||
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support:
|
||||
|
||||
- Star this repository
|
||||
- Report bugs and suggest features
|
||||
- Improve documentation
|
||||
- Help with translations
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#AmruthPillai/Reactive-Resume&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date" />
|
||||
</picture>
|
||||
<a href="https://www.star-history.com/#amruthpillai/reactive-resume&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make open-source thrive. Whether fixing a typo or adding a feature, all contributions are welcome.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
|
||||
|
||||
## License
|
||||
|
||||
Reactive Resume is packaged and distributed using the [MIT License](/LICENSE.md) which allows for commercial use, distribution, modification and private use provided that all copies of the software contain the same license and copyright.
|
||||
|
||||
_By the community, for the community._
|
||||
A passion project by [Amruth Pillai](https://www.amruthpillai.com/)
|
||||
|
||||
<p>
|
||||
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=Reactive-Resume">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="200px">
|
||||
</a>
|
||||
</p>
|
||||
[MIT](./LICENSE) — do whatever you want with it.
|
||||
|
||||
@@ -2,13 +2,33 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Reactive Resume provides security updates for version `5.0.x` and newer.
|
||||
Earlier major versions are no longer supported.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 4.x.x | :white_check_mark: |
|
||||
| 3.x.x | :x: |
|
||||
| 2.x.x | :x: |
|
||||
| 1.x.x | :x: |
|
||||
| >= 5.0 | :white_check_mark: |
|
||||
| 4.x | :x: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please raise an issue on GitHub to report any security vulnerabilities in the app. If the vulnerability is potentially lethal, email me about it on hello@amruthpillai.com.
|
||||
If you believe you have found a security vulnerability, please report it
|
||||
privately instead of opening a public issue.
|
||||
|
||||
Preferred reporting channels:
|
||||
|
||||
- Use GitHub's private vulnerability reporting for this repository.
|
||||
- If that is unavailable, email `hello@amruthpillai.com` with the subject
|
||||
`Security Vulnerability: Reactive Resume`.
|
||||
|
||||
Please include as much detail as possible, including:
|
||||
|
||||
- A description of the vulnerability and its impact.
|
||||
- Steps to reproduce or a proof of concept.
|
||||
- Affected versions, configuration, or deployment details.
|
||||
- Any relevant logs, screenshots, or links.
|
||||
|
||||
You can expect an initial response within 7 days. If the report is accepted,
|
||||
we will coordinate remediation and disclosure timing with you before publishing
|
||||
details publicly.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"extends": ["plugin:tailwindcss/recommended"],
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
"callees": ["cn", "clsx", "cva"],
|
||||
"config": "tailwind.config.js"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
// eslint
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
// react
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/jsx-sort-props": [
|
||||
"error",
|
||||
{
|
||||
"reservedFirst": true,
|
||||
"callbacksLast": true,
|
||||
"shorthandFirst": true,
|
||||
"noSortAlphabetically": true
|
||||
}
|
||||
],
|
||||
|
||||
// react-hooks
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
|
||||
// tailwindcss
|
||||
"tailwindcss/no-custom-classname": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US" translate="no">
|
||||
<head>
|
||||
<base href="/" />
|
||||
|
||||
<!-- SEO -->
|
||||
<title>Reactive Resume - A free and open-source resume builder</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
/>
|
||||
|
||||
<!-- Meta -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="googlebot" content="notranslate" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/icon/dark.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/icon/light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/src/styles/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
<!-- Phosphor Icons -->
|
||||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||
|
||||
<!-- Simple Icons -->
|
||||
<link
|
||||
type="text/css"
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/simple-icons-font@v14/font/simple-icons.min.css"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
const path = require("node:path");
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {
|
||||
config: path.join(__dirname, "tailwind.config.js"),
|
||||
},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "artboard",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/artboard/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/artboard"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"mode": "development"
|
||||
},
|
||||
"production": {
|
||||
"mode": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:dev-server",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "artboard:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "artboard:build:development",
|
||||
"hmr": true
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "artboard:build:production",
|
||||
"hmr": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"executor": "@nx/vite:preview-server",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "artboard:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "artboard:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "artboard:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/artboard/**/*.{ts,tsx,js,jsx}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["frontend"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" fill="none"><rect width="256" height="256" rx="80" style="fill:#000;stroke:none;stroke-width:.396357"/><g fill="#09090b" fill-rule="evenodd" clip-rule="evenodd"><path d="m282.857 257.192-37.64 50.15 39.21.004 17.95-23.88 17.95 23.876h39.21l-75.11-100.256-39.21-.003zm23.775-31.764 13.696-18.345h39.21l-33.234 44.435zM119.537 135.21v129.485h36.626V230.29h19.993c17.99-.01 40.841-3.946 52.762-21.828 4.686-7.152 7.03-15.6 7.03-25.342 0-9.865-2.344-18.374-7.03-25.527-4.687-7.276-11.346-12.825-19.978-16.648-8.51-3.823-18.37-5.735-30.21-5.735zm90.963 95.183s-14.972 6.285-34.681 6.047l21.162 28.255h39.21zm-54.337-28.405h20.348c7.646 0 13.319-1.665 17.018-4.995 3.823-3.33 5.735-7.954 5.735-13.874 0-6.042-1.912-10.729-5.735-14.058-3.7-3.33-9.372-4.995-17.018-4.995h-20.348z" style="fill:#fafafa;fill-opacity:1;stroke-width:.9375" transform="matrix(.91667 0 0 .91667 -91.576 -74.838)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 958 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#09090b" fill-rule="evenodd" d="m16.332 15.592-3.764 5.015h3.921l1.795-2.388 1.795 2.388H24L16.489 10.58h-3.921Zm2.377-3.177 1.37-1.834H24l-3.323 4.443zM0 3.393v12.949h3.663v-3.44h1.999c1.799-.002 4.084-.395 5.276-2.183.469-.716.703-1.56.703-2.535 0-.986-.234-1.837-.703-2.552-.469-.728-1.135-1.283-1.998-1.665-.85-.382-1.837-.574-3.02-.574Zm9.096 9.519s-1.497.628-3.468.604l2.116 2.826h3.921zm-5.433-2.84h2.034c.765 0 1.332-.167 1.702-.5.382-.333.574-.796.574-1.388 0-.604-.192-1.073-.574-1.405-.37-.333-.937-.5-1.702-.5H3.663Z" clip-rule="evenodd" style="stroke-width:.09375"/></svg>
|
||||
|
Before Width: | Height: | Size: 672 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#09090b" fill-rule="evenodd" d="m16.332 15.592-3.764 5.015h3.921l1.795-2.388 1.795 2.388H24L16.489 10.58h-3.921Zm2.377-3.177 1.37-1.834H24l-3.323 4.443zM0 3.393v12.949h3.663v-3.44h1.999c1.799-.002 4.084-.395 5.276-2.183.469-.716.703-1.56.703-2.535 0-.986-.234-1.837-.703-2.552-.469-.728-1.135-1.283-1.998-1.665-.85-.382-1.837-.574-3.02-.574Zm9.096 9.519s-1.497.628-3.468.604l2.116 2.826h3.921zm-5.433-2.84h2.034c.765 0 1.332-.167 1.702-.5.382-.333.574-.796.574-1.388 0-.604-.192-1.073-.574-1.405-.37-.333-.937-.5-1.702-.5H3.663Z" clip-rule="evenodd" style="stroke-width:.09375;fill:#fafafa;fill-opacity:1"/></svg>
|
||||
|
Before Width: | Height: | Size: 700 B |
@@ -1,19 +0,0 @@
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type BrandIconProps = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const BrandIcon = ({ slug }: BrandIconProps) => {
|
||||
if (slug === "linkedin") {
|
||||
return (
|
||||
<img
|
||||
alt="LinkedIn"
|
||||
className="size-4"
|
||||
src={`${window.location.origin}/support-logos/linkedin.svg`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <i className={cn("si si--color text-[1rem]", `si-${slug}`)} />;
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { cn, pageSizeMap } from "@reactive-resume/utils";
|
||||
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
|
||||
type Props = {
|
||||
mode?: "preview" | "builder";
|
||||
pageNumber: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MM_TO_PX = 3.78;
|
||||
|
||||
export const Page = ({ mode = "preview", pageNumber, children }: Props) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
const page = useArtboardStore((state) => state.resume.metadata.page);
|
||||
const fontFamily = useArtboardStore((state) => state.resume.metadata.typography.font.family);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-page={pageNumber}
|
||||
className={cn("relative bg-background text-foreground", mode === "builder" && "shadow-2xl")}
|
||||
style={{
|
||||
fontFamily,
|
||||
width: `${pageSizeMap[page.format].width * MM_TO_PX}px`,
|
||||
minHeight: `${pageSizeMap[page.format].height * MM_TO_PX}px`,
|
||||
}}
|
||||
>
|
||||
{mode === "builder" && page.options.pageNumbers && (
|
||||
<div className={cn("absolute -top-7 left-0 font-bold", isDarkMode && "text-white")}>
|
||||
Page {pageNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{mode === "builder" && page.options.breakLine && (
|
||||
<div
|
||||
className="absolute inset-x-0 border-b border-dashed"
|
||||
style={{
|
||||
top: `${pageSizeMap[page.format].height * MM_TO_PX}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn, isUrl } from "@reactive-resume/utils";
|
||||
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
|
||||
type PictureProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Picture = ({ className }: PictureProps) => {
|
||||
const picture = useArtboardStore((state) => state.resume.basics.picture);
|
||||
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
|
||||
|
||||
if (!isUrl(picture.url) || picture.effects.hidden) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={picture.url}
|
||||
alt="Profile"
|
||||
className={cn(
|
||||
"relative z-20 object-cover",
|
||||
picture.effects.border && "border-primary",
|
||||
picture.effects.grayscale && "grayscale",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
maxWidth: `${picture.size}px`,
|
||||
aspectRatio: `${picture.aspectRatio}`,
|
||||
borderRadius: `${picture.borderRadius}px`,
|
||||
borderWidth: `${picture.effects.border ? fontSize / 3 : 0}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { router } from "./router";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const root = ReactDOM.createRoot(document.querySelector("#root")!);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import webfontloader from "webfontloader";
|
||||
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
|
||||
export const ArtboardPage = () => {
|
||||
const metadata = useArtboardStore((state) => state.resume.metadata);
|
||||
|
||||
const fontString = useMemo(() => {
|
||||
const family = metadata.typography.font.family;
|
||||
const variants = metadata.typography.font.variants.join(",");
|
||||
const subset = metadata.typography.font.subset;
|
||||
|
||||
return `${family}:${variants}:${subset}`;
|
||||
}, [metadata.typography.font]);
|
||||
|
||||
useEffect(() => {
|
||||
webfontloader.load({
|
||||
google: { families: [fontString] },
|
||||
active: () => {
|
||||
const width = window.document.body.offsetWidth;
|
||||
const height = window.document.body.offsetHeight;
|
||||
const message = { type: "PAGE_LOADED", payload: { width, height } };
|
||||
window.postMessage(message, "*");
|
||||
},
|
||||
});
|
||||
}, [fontString]);
|
||||
|
||||
// Font Size & Line Height
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("font-size", `${metadata.typography.font.size}px`);
|
||||
document.documentElement.style.setProperty("line-height", `${metadata.typography.lineHeight}`);
|
||||
|
||||
document.documentElement.style.setProperty("--margin", `${metadata.page.margin}px`);
|
||||
document.documentElement.style.setProperty("--font-size", `${metadata.typography.font.size}px`);
|
||||
document.documentElement.style.setProperty(
|
||||
"--line-height",
|
||||
`${metadata.typography.lineHeight}`,
|
||||
);
|
||||
|
||||
document.documentElement.style.setProperty("--color-foreground", metadata.theme.text);
|
||||
document.documentElement.style.setProperty("--color-primary", metadata.theme.primary);
|
||||
document.documentElement.style.setProperty("--color-background", metadata.theme.background);
|
||||
}, [metadata]);
|
||||
|
||||
// Typography Options
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line unicorn/prefer-spread
|
||||
const elements = Array.from(document.querySelectorAll(`[data-page]`));
|
||||
|
||||
for (const el of elements) {
|
||||
el.classList.toggle("hide-icons", metadata.typography.hideIcons);
|
||||
el.classList.toggle("underline-links", metadata.typography.underlineLinks);
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
import { pageSizeMap, Template } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
|
||||
import { MM_TO_PX, Page } from "../components/page";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { getTemplate } from "../templates";
|
||||
|
||||
export const BuilderLayout = () => {
|
||||
const [wheelPanning, setWheelPanning] = useState(true);
|
||||
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
|
||||
const layout = useArtboardStore((state) => state.resume.metadata.layout);
|
||||
const format = useArtboardStore((state) => state.resume.metadata.page.format);
|
||||
const template = useArtboardStore((state) => state.resume.metadata.template as Template);
|
||||
|
||||
const Template = useMemo(() => getTemplate(template), [template]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === "ZOOM_IN") transformRef.current?.zoomIn(0.2);
|
||||
if (event.data.type === "ZOOM_OUT") transformRef.current?.zoomOut(0.2);
|
||||
if (event.data.type === "CENTER_VIEW") transformRef.current?.centerView();
|
||||
if (event.data.type === "RESET_VIEW") {
|
||||
transformRef.current?.resetTransform(0);
|
||||
setTimeout(() => transformRef.current?.centerView(0.8, 0), 10);
|
||||
}
|
||||
if (event.data.type === "TOGGLE_PAN_MODE") {
|
||||
setWheelPanning(event.data.panMode);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [transformRef]);
|
||||
|
||||
return (
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
centerOnInit
|
||||
maxScale={2}
|
||||
minScale={0.4}
|
||||
initialScale={0.8}
|
||||
limitToBounds={false}
|
||||
wheel={{ wheelDisabled: wheelPanning }}
|
||||
panning={{ wheelPanning: wheelPanning }}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperClass="!w-screen !h-screen"
|
||||
contentClass="grid items-start justify-center space-x-12 pointer-events-none"
|
||||
contentStyle={{
|
||||
width: `${layout.length * (pageSizeMap[format].width * MM_TO_PX + 42)}px`,
|
||||
gridTemplateColumns: `repeat(${layout.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{layout.map((columns, pageIndex) => (
|
||||
<motion.div
|
||||
key={pageIndex}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -200, y: 0 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: pageIndex * 0.3 } }}
|
||||
exit={{ opacity: 0, x: -200 }}
|
||||
>
|
||||
<Page mode="builder" pageNumber={pageIndex + 1}>
|
||||
<Template isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||
</Page>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
import { Template } from "@reactive-resume/utils";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Page } from "../components/page";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { getTemplate } from "../templates";
|
||||
|
||||
export const PreviewLayout = () => {
|
||||
const layout = useArtboardStore((state) => state.resume.metadata.layout);
|
||||
const template = useArtboardStore((state) => state.resume.metadata.template as Template);
|
||||
|
||||
const Template = useMemo(() => getTemplate(template), [template]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layout.map((columns, pageIndex) => (
|
||||
<Page key={pageIndex} mode="preview" pageNumber={pageIndex + 1}>
|
||||
<Template isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||
</Page>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
|
||||
export const Providers = () => {
|
||||
const resume = useArtboardStore((state) => state.resume);
|
||||
const setResume = useArtboardStore((state) => state.setResume);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === "SET_RESUME") setResume(event.data.payload);
|
||||
if (event.data.type === "SET_THEME") {
|
||||
event.data.payload === "dark"
|
||||
? document.documentElement.classList.add("dark")
|
||||
: document.documentElement.classList.remove("dark");
|
||||
}
|
||||
};
|
||||
|
||||
const resumeData = window.localStorage.getItem("resume");
|
||||
if (resumeData) {
|
||||
setResume(JSON.parse(resumeData));
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [setResume]);
|
||||
|
||||
// Only for testing, in production this will be fetched from window.postMessage
|
||||
// useEffect(() => {
|
||||
// setResume(sampleResume);
|
||||
// }, [setResume]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!resume) return null;
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createBrowserRouter, createRoutesFromChildren, Route } from "react-router-dom";
|
||||
|
||||
import { ArtboardPage } from "../pages/artboard";
|
||||
import { BuilderLayout } from "../pages/builder";
|
||||
import { PreviewLayout } from "../pages/preview";
|
||||
import { Providers } from "../providers";
|
||||
|
||||
export const routes = createRoutesFromChildren(
|
||||
<Route element={<Providers />}>
|
||||
<Route path="artboard" element={<ArtboardPage />}>
|
||||
<Route path="builder" element={<BuilderLayout />} />
|
||||
<Route path="preview" element={<PreviewLayout />} />
|
||||
</Route>
|
||||
</Route>,
|
||||
);
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ResumeData } from "@reactive-resume/schema";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ArtboardStore = {
|
||||
resume: ResumeData;
|
||||
setResume: (resume: ResumeData) => void;
|
||||
};
|
||||
|
||||
export const useArtboardStore = create<ArtboardStore>()((set) => ({
|
||||
resume: null as unknown as ResumeData,
|
||||
setResume: (resume) => {
|
||||
set({ resume });
|
||||
},
|
||||
}));
|
||||
@@ -1,25 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
font-variant-ligatures: none;
|
||||
|
||||
@apply border-current;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
[data-page].hide-icons .ph {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-page].underline-links a {
|
||||
@apply underline underline-offset-2;
|
||||
}
|
||||
|
||||
.wysiwyg {
|
||||
@apply prose-foreground prose max-w-none prose-headings:mb-2 prose-headings:mt-0 prose-p:mb-2 prose-p:mt-0 prose-p:leading-normal prose-a:break-all prose-ol:mb-2 prose-ol:mt-0 prose-ul:mb-2 prose-ul:mt-0 prose-li:mb-2 prose-li:mt-0 prose-li:leading-normal prose-img:mb-2 prose-img:mt-0 prose-hr:mb-2 prose-hr:mt-0;
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl, linearTransform } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-2 pb-2 text-center">
|
||||
<Picture />
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<div className="mb-2 hidden font-bold text-primary group-[.main]:block">
|
||||
<h4>{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 hidden items-center gap-x-2 text-center font-bold text-primary group-[.sidebar]:flex">
|
||||
<div className="size-1.5 rounded-full border border-primary" />
|
||||
<h4>{section.name}</h4>
|
||||
<div className="size-1.5 rounded-full border border-primary" />
|
||||
</div>
|
||||
|
||||
<main className={cn("relative space-y-2", "border-l border-primary pl-4")}>
|
||||
<div className="absolute left-[-4.5px] top-[8px] hidden size-[8px] rounded-full bg-primary group-[.main]:block" />
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="relative h-1 w-[128px] group-[.sidebar]:mx-auto">
|
||||
<div className="absolute inset-0 h-1 w-[128px] rounded bg-primary opacity-25" />
|
||||
<div
|
||||
className="absolute inset-0 h-1 rounded bg-primary"
|
||||
style={{ width: linearTransform(level, 0, 5, 0, 128) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<div className="mb-2 hidden font-bold text-primary group-[.main]:block">
|
||||
<h4>{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-2 hidden items-center gap-x-2 text-center font-bold text-primary group-[.sidebar]:flex">
|
||||
<div className="size-1.5 rounded-full border border-primary" />
|
||||
<h4>{section.name}</h4>
|
||||
<div className="size-1.5 rounded-full border border-primary" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3 group-[.sidebar]:mx-auto group-[.sidebar]:text-center"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative space-y-2",
|
||||
"border-primary group-[.main]:border-l group-[.main]:pl-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>{children?.(item as T)}</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
|
||||
<div className="absolute left-[-4.5px] top-px hidden size-[8px] rounded-full bg-primary group-[.main]:block" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
<div>{item.studyType}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity name={item.awarder} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Azurill = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-3">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="grid grid-cols-3 gap-x-4">
|
||||
<div className="sidebar group space-y-4">
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("main group space-y-4", sidebar.length > 0 ? "col-span-2" : "col-span-3")}
|
||||
>
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,584 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Picture />
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid grid-cols-5 border-t pt-2.5">
|
||||
<div>
|
||||
<h4 className="text-base font-bold">{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg col-span-4"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("size-2 rounded-full border border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid grid-cols-5 border-t pt-2.5">
|
||||
<div>
|
||||
<h4 className="text-base font-bold">{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="col-span-4 grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Bronzor = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-4">
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,599 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Picture />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"size-2 rounded-full border border-primary group-[.sidebar]:border-background",
|
||||
level > index && "bg-primary group-[.sidebar]:bg-background",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight &&
|
||||
(icon ?? <i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-white" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight &&
|
||||
(icon ?? <i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-white" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: summary }}
|
||||
className="wysiwyg group-[.sidebar]:prose-invert"
|
||||
/>
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Chikorita = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[inherit] grid-cols-3">
|
||||
<div
|
||||
className={cn(
|
||||
"main p-custom group space-y-4",
|
||||
sidebar.length > 0 ? "col-span-2" : "col-span-3",
|
||||
)}
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"sidebar p-custom group h-full space-y-4 bg-primary text-background",
|
||||
sidebar.length === 0 && "hidden",
|
||||
)}
|
||||
>
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,625 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="p-custom relative grid grid-cols-3 space-x-4 pb-0">
|
||||
<Picture className="mx-auto" />
|
||||
|
||||
<div className="relative z-10 col-span-2 text-background">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-3xl font-bold">{basics.name}</h2>
|
||||
<p>{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 col-start-2 mt-10 text-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
<div className="bg-text size-1 rounded-full last:hidden" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-text size-1 rounded-full last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{basics.email && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-text size-1 rounded-full last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{isUrl(basics.url.href) && (
|
||||
<>
|
||||
<Link url={basics.url} />
|
||||
<div className="bg-text size-1 rounded-full last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{basics.customFields.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-text size-1 rounded-full last:hidden" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 text-base font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("h-2 w-4 border border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 text-base font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn("relative space-y-2 pl-4 group-[.sidebar]:pl-0", className)}
|
||||
>
|
||||
<div className="relative -ml-4 group-[.sidebar]:ml-0">
|
||||
<div className="pl-4 group-[.sidebar]:pl-0">
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 -left-px border-l-4 border-primary group-[.sidebar]:hidden" />
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-y-0 left-0 border-l border-primary group-[.sidebar]:hidden" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} className="space-y-0" keywordsKey="keywords">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Ditto = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isFirstPage && (
|
||||
<div className="relative">
|
||||
<Header />
|
||||
<div className="absolute inset-x-0 top-0 h-[85px] w-full bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="sidebar p-custom group space-y-4">
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"main p-custom group space-y-4",
|
||||
sidebar.length > 0 ? "col-span-2" : "col-span-3",
|
||||
)}
|
||||
>
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,604 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, hexToRgb, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4 bg-primary text-background">
|
||||
<Picture className="border-background" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{basics.name}</h2>
|
||||
<p>{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-y-2 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{isUrl(basics.url.href) && <Link url={basics.url} />}
|
||||
{basics.customFields.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`)} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
const primaryColor = useArtboardStore((state) => state.resume.metadata.theme.primary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4" style={{ backgroundColor: hexToRgb(primaryColor, 0.2) }}>
|
||||
<section id={section.id}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("h-2.5 w-5 border border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight &&
|
||||
(icon ?? (
|
||||
<i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-background" />
|
||||
))}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight &&
|
||||
(icon ?? (
|
||||
<i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-background" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary group-[.sidebar]:text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b border-primary text-base font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} className="space-y-1" keywordsKey="keywords">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Gengar = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
const primaryColor = useArtboardStore((state) => state.resume.metadata.theme.primary);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[inherit] grid-cols-3">
|
||||
<div
|
||||
className={cn(
|
||||
"sidebar group flex flex-col",
|
||||
!(isFirstPage || sidebar.length > 0) && "hidden",
|
||||
)}
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div
|
||||
className="p-custom flex-1 space-y-4"
|
||||
style={{ backgroundColor: hexToRgb(primaryColor, 0.2) }}
|
||||
>
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("main group", sidebar.length > 0 ? "col-span-2" : "col-span-3")}>
|
||||
{isFirstPage && <Summary />}
|
||||
|
||||
<div className="p-custom space-y-4">
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,604 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, hexToRgb, isEmptyString, isUrl, linearTransform } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<Picture />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-y-1.5 rounded border border-primary px-3 py-4 text-left text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon} text-primary`)} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => {
|
||||
const primaryColor = useArtboardStore((state) => state.resume.metadata.theme.primary);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-2.5 w-full rounded-sm"
|
||||
style={{ backgroundColor: hexToRgb(primaryColor, 0.4) }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 h-2.5 w-full rounded-sm bg-primary"
|
||||
style={{ width: `${linearTransform(level, 0, 5, 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight &&
|
||||
(icon ?? <i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight &&
|
||||
(icon ?? <i className="ph ph-bold ph-link text-primary group-[.sidebar]:text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary group-[.sidebar]:text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold group-[.sidebar]:text-primary">
|
||||
{section.name}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Glalie = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
const primaryColor = useArtboardStore((state) => state.resume.metadata.theme.primary);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[inherit] grid-cols-3">
|
||||
<div
|
||||
className={cn("sidebar p-custom group space-y-4", sidebar.length === 0 && "hidden")}
|
||||
style={{ backgroundColor: hexToRgb(primaryColor, 0.2) }}
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"main p-custom group space-y-4",
|
||||
sidebar.length > 0 ? "col-span-2" : "col-span-3",
|
||||
)}
|
||||
>
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Template } from "@reactive-resume/utils";
|
||||
|
||||
import { Azurill } from "./azurill";
|
||||
import { Bronzor } from "./bronzor";
|
||||
import { Chikorita } from "./chikorita";
|
||||
import { Ditto } from "./ditto";
|
||||
import { Gengar } from "./gengar";
|
||||
import { Glalie } from "./glalie";
|
||||
import { Kakuna } from "./kakuna";
|
||||
import { Leafish } from "./leafish";
|
||||
import { Nosepass } from "./nosepass";
|
||||
import { Onyx } from "./onyx";
|
||||
import { Pikachu } from "./pikachu";
|
||||
import { Rhyhorn } from "./rhyhorn";
|
||||
|
||||
export const getTemplate = (template: Template) => {
|
||||
switch (template) {
|
||||
case "azurill": {
|
||||
return Azurill;
|
||||
}
|
||||
case "bronzor": {
|
||||
return Bronzor;
|
||||
}
|
||||
case "chikorita": {
|
||||
return Chikorita;
|
||||
}
|
||||
case "ditto": {
|
||||
return Ditto;
|
||||
}
|
||||
case "gengar": {
|
||||
return Gengar;
|
||||
}
|
||||
case "glalie": {
|
||||
return Glalie;
|
||||
}
|
||||
case "kakuna": {
|
||||
return Kakuna;
|
||||
}
|
||||
case "leafish": {
|
||||
return Leafish;
|
||||
}
|
||||
case "nosepass": {
|
||||
return Nosepass;
|
||||
}
|
||||
case "onyx": {
|
||||
return Onyx;
|
||||
}
|
||||
case "pikachu": {
|
||||
return Pikachu;
|
||||
}
|
||||
case "rhyhorn": {
|
||||
return Rhyhorn;
|
||||
}
|
||||
default: {
|
||||
return Onyx;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,540 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-2 pb-2 text-center">
|
||||
<Picture />
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link url={basics.url} />
|
||||
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{profiles.visible && profiles.items.length > 0 && (
|
||||
<div className="flex items-center gap-x-3 gap-y-0.5">
|
||||
{profiles.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-2">
|
||||
<Link
|
||||
url={item.url}
|
||||
label={item.username}
|
||||
className="text-sm"
|
||||
icon={<BrandIcon slug={item.icon} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 border-b border-primary text-center font-bold text-primary">
|
||||
{section.name}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("h-3 w-5 rounded border-2 border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b border-primary text-center font-bold text-primary">
|
||||
{section.name}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>{children?.(item as T)}</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
<div>{item.studyType}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity name={item.awarder} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Kakuna = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-4">
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,536 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, hexToRgb, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
const primaryColor = useArtboardStore((state) => state.resume.metadata.theme.primary);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="p-custom flex items-center space-x-8"
|
||||
style={{ backgroundColor: hexToRgb(primaryColor, 0.2) }}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{basics.name}</div>
|
||||
<div className="text-base font-medium text-primary">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Picture />
|
||||
</div>
|
||||
|
||||
<div className="p-custom space-y-3" style={{ backgroundColor: hexToRgb(primaryColor, 0.4) }}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{profiles.visible && profiles.items.length > 0 && (
|
||||
<div className="flex items-center gap-x-3 gap-y-0.5">
|
||||
{profiles.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-2">
|
||||
<Link
|
||||
url={item.url}
|
||||
label={item.username}
|
||||
className="text-sm"
|
||||
icon={<BrandIcon slug={item.icon} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("h-3 w-6 border-2 border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b border-primary text-left font-bold text-primary">
|
||||
{section.name}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>{children?.(item as T)}</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
<div>{item.studyType}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity name={item.awarder} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div>
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
<div>{item.location}</div>
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Leafish = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="p-custom grid grid-cols-2 items-start space-x-6">
|
||||
<div className={cn("grid gap-y-4", sidebar.length === 0 && "col-span-2")}>
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={cn("grid gap-y-4", sidebar.length === 0 && "hidden")}>
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-x-6">
|
||||
<div className="mt-1 space-y-2 text-right">
|
||||
<Picture className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 space-y-2">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 text-sm">
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary">{item.name}</span>
|
||||
<span>{item.value}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid grid-cols-4 gap-x-6">
|
||||
<div className="text-right">
|
||||
<h4 className="font-medium text-primary">{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<div className="relative">
|
||||
<hr className="mt-3 border-primary pb-3" />
|
||||
<div className="absolute bottom-3 right-0 size-3 bg-primary" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
urlKey?: keyof T;
|
||||
dateKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
urlKey,
|
||||
dateKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className={cn("grid", dateKey !== undefined && "gap-y-4")}>
|
||||
<div className="grid grid-cols-4 gap-x-6">
|
||||
<div className="text-right">
|
||||
<h4 className="font-medium text-primary">{section.name}</h4>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<div className="relative">
|
||||
<hr className="mt-3 border-primary" />
|
||||
<div className="absolute bottom-0 right-0 size-3 bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateKey !== undefined && (
|
||||
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const date = (dateKey && get(item, dateKey, "")) as string | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className="text-right font-medium text-primary">{date}</div>
|
||||
|
||||
<div className="col-span-3 space-y-1">
|
||||
{children?.(item as T)}
|
||||
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dateKey === undefined && (
|
||||
<div className="grid grid-cols-4 gap-x-6">
|
||||
<div
|
||||
className="col-span-3 col-start-2 grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as
|
||||
| string[]
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{children?.(item as T)}
|
||||
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.studyType}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity name={item.awarder} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" dateKey="date" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
dateKey="date"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
dateKey="date"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Nosepass = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const name = useArtboardStore((state) => state.resume.basics.name);
|
||||
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<img alt="Europass Logo" className="h-[42px]" src="/assets/europass.png" />
|
||||
|
||||
<p className="font-medium text-primary">Curriculum Vitae</p>
|
||||
|
||||
<p className="font-medium text-primary">{name}</p>
|
||||
</div>
|
||||
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-4">
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,578 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-4 border-b border-primary pb-5">
|
||||
<Picture />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profiles.visible && profiles.items.length > 0 && (
|
||||
<div
|
||||
className="grid gap-x-4 gap-y-1 text-right"
|
||||
style={{ gridTemplateColumns: `repeat(${profiles.columns}, auto)` }}
|
||||
>
|
||||
{profiles.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-x-2">
|
||||
<Link
|
||||
url={item.url}
|
||||
label={item.username}
|
||||
className="text-sm"
|
||||
icon={<BrandIcon slug={item.icon} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="font-bold text-primary">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("size-3 rounded border-2 border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="font-bold text-primary">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Onyx = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,621 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
const borderRadius = useArtboardStore((state) => state.resume.basics.picture.borderRadius);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="summary group bg-primary px-6 pb-7 pt-6 text-background"
|
||||
style={{ borderRadius: `calc(${borderRadius}px - 2px)` }}
|
||||
>
|
||||
<div className="col-span-2 space-y-2.5">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{basics.name}</h2>
|
||||
<p>{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<hr className="border-background opacity-50" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-map-pin" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
<div className="size-1 rounded-full bg-background last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-phone" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
<div className="size-1 rounded-full bg-background last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{basics.email && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className="ph ph-bold ph-at" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="size-1 rounded-full bg-background last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{isUrl(basics.url.href) && (
|
||||
<>
|
||||
<Link url={basics.url} />
|
||||
<div className="size-1 rounded-full bg-background last:hidden" />
|
||||
</>
|
||||
)}
|
||||
{basics.customFields.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`)} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="size-1 rounded-full bg-background last:hidden" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 border-b border-primary text-base font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<i
|
||||
key={index}
|
||||
className={cn(
|
||||
"ph ph-diamond text-primary",
|
||||
level > index && "ph-fill",
|
||||
level <= index && "ph-bold",
|
||||
)}
|
||||
/>
|
||||
// <div
|
||||
// key={index}
|
||||
// className={cn("h-2 w-4 border border-primary", level > index && "bg-primary")}
|
||||
// />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{!iconOnRight &&
|
||||
(icon ?? (
|
||||
<i className="ph ph-bold ph-link text-primary group-[.summary]:text-background" />
|
||||
))}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight &&
|
||||
(icon ?? (
|
||||
<i className="ph ph-bold ph-link text-primary group-[.summary]:text-background" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary group-[.summary]:text-background" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b border-primary text-base font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} className="space-y-1" keywordsKey="keywords">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right group-[.sidebar]:text-left">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Pikachu = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom grid grid-cols-3 space-x-6">
|
||||
<div className="sidebar group space-y-4">
|
||||
{isFirstPage && <Picture className="w-full !max-w-none" />}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={cn("main group space-y-4", sidebar.length > 0 ? "col-span-2" : "col-span-3")}>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,581 +0,0 @@
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSection,
|
||||
CustomSectionGroup,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
SectionKey,
|
||||
SectionWithItem,
|
||||
Skill,
|
||||
URL,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { BrandIcon } from "../components/brand-icon";
|
||||
import { Picture } from "../components/picture";
|
||||
import { useArtboardStore } from "../store/artboard";
|
||||
import { TemplateProps } from "../types/template";
|
||||
|
||||
const Header = () => {
|
||||
const basics = useArtboardStore((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Picture />
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-2xl font-bold">{basics.name}</div>
|
||||
<div className="text-base">{basics.headline}</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
|
||||
<i className="ph ph-bold ph-map-pin text-primary" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
|
||||
<i className="ph ph-bold ph-phone text-primary" />
|
||||
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
|
||||
{basics.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
|
||||
<i className="ph ph-bold ph-at text-primary" />
|
||||
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
|
||||
{basics.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Link url={basics.url} />
|
||||
{basics.customFields.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0"
|
||||
>
|
||||
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
|
||||
{isUrl(item.value) ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{item.name || item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Summary = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.summary);
|
||||
|
||||
if (!section.visible || isEmptyString(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id}>
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: section.content }}
|
||||
className="wysiwyg"
|
||||
style={{ columns: section.columns }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = { level: number };
|
||||
|
||||
const Rating = ({ level }: RatingProps) => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("size-2 rounded-full border border-primary", level > index && "bg-primary")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type LinkProps = {
|
||||
url: URL;
|
||||
icon?: React.ReactNode;
|
||||
iconOnRight?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Link = ({ url, icon, iconOnRight, label, className }: LinkProps) => {
|
||||
if (!isUrl(url.href)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
|
||||
{!iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
<a
|
||||
href={url.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
className={cn("inline-block", className)}
|
||||
>
|
||||
{label ?? (url.label || url.href)}
|
||||
</a>
|
||||
{iconOnRight && (icon ?? <i className="ph ph-bold ph-link text-primary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkedEntityProps = {
|
||||
name: string;
|
||||
url: URL;
|
||||
separateLinks: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LinkedEntity = ({ name, url, separateLinks, className }: LinkedEntityProps) => {
|
||||
return !separateLinks && isUrl(url.href) ? (
|
||||
<Link
|
||||
url={url}
|
||||
label={name}
|
||||
icon={<i className="ph ph-bold ph-globe text-primary" />}
|
||||
iconOnRight={true}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{name}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps<T> = {
|
||||
section: SectionWithItem<T> | CustomSectionGroup;
|
||||
children?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
urlKey?: keyof T;
|
||||
levelKey?: keyof T;
|
||||
summaryKey?: keyof T;
|
||||
keywordsKey?: keyof T;
|
||||
};
|
||||
|
||||
const Section = <T,>({
|
||||
section,
|
||||
children,
|
||||
className,
|
||||
urlKey,
|
||||
levelKey,
|
||||
summaryKey,
|
||||
keywordsKey,
|
||||
}: SectionProps<T>) => {
|
||||
if (!section.visible || section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="grid">
|
||||
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
|
||||
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-3"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{section.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => {
|
||||
const url = (urlKey && get(item, urlKey)) as URL | undefined;
|
||||
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
|
||||
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
|
||||
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn("space-y-2", className)}>
|
||||
<div>
|
||||
{children?.(item as T)}
|
||||
{url !== undefined && section.separateLinks && <Link url={url} />}
|
||||
</div>
|
||||
|
||||
{summary !== undefined && !isEmptyString(summary) && (
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
|
||||
)}
|
||||
|
||||
{level !== undefined && level > 0 && <Rating level={level} />}
|
||||
|
||||
{keywords !== undefined && keywords.length > 0 && (
|
||||
<p className="text-sm">{keywords.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Profiles = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.profiles);
|
||||
|
||||
return (
|
||||
<Section<Profile> section={section}>
|
||||
{(item) => (
|
||||
<div>
|
||||
{isUrl(item.url.href) ? (
|
||||
<Link url={item.url} label={item.username} icon={<BrandIcon slug={item.icon} />} />
|
||||
) : (
|
||||
<p>{item.username}</p>
|
||||
)}
|
||||
{!item.icon && <p className="text-sm">{item.network}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Experience = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.experience);
|
||||
|
||||
return (
|
||||
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.company}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Education = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.education);
|
||||
|
||||
return (
|
||||
<Section<Education> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.institution}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.area}</div>
|
||||
<div>{item.score}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.studyType}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Awards = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.awards);
|
||||
|
||||
return (
|
||||
<Section<Award> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<LinkedEntity
|
||||
name={item.awarder}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Certifications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.certifications);
|
||||
|
||||
return (
|
||||
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<LinkedEntity name={item.issuer} url={item.url} separateLinks={section.separateLinks} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.skills);
|
||||
|
||||
return (
|
||||
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Interests = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.interests);
|
||||
|
||||
return (
|
||||
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
|
||||
{(item) => <div className="font-bold">{item.name}</div>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Publications = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.publications);
|
||||
|
||||
return (
|
||||
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.publisher}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Volunteer = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.volunteer);
|
||||
|
||||
return (
|
||||
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.organization}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.position}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Languages = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.languages);
|
||||
|
||||
return (
|
||||
<Section<Language> section={section} levelKey="level">
|
||||
{(item) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.projects);
|
||||
|
||||
return (
|
||||
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const References = () => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.references);
|
||||
|
||||
return (
|
||||
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
|
||||
{(item) => (
|
||||
<div>
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Custom = ({ id }: { id: string }) => {
|
||||
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
|
||||
|
||||
return (
|
||||
<Section<CustomSection>
|
||||
section={section}
|
||||
urlKey="url"
|
||||
summaryKey="summary"
|
||||
keywordsKey="keywords"
|
||||
>
|
||||
{(item) => (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-left">
|
||||
<LinkedEntity
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
separateLinks={section.separateLinks}
|
||||
className="font-bold"
|
||||
/>
|
||||
<div>{item.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-bold">{item.date}</div>
|
||||
<div>{item.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const mapSectionToComponent = (section: SectionKey) => {
|
||||
switch (section) {
|
||||
case "profiles": {
|
||||
return <Profiles />;
|
||||
}
|
||||
case "summary": {
|
||||
return <Summary />;
|
||||
}
|
||||
case "experience": {
|
||||
return <Experience />;
|
||||
}
|
||||
case "education": {
|
||||
return <Education />;
|
||||
}
|
||||
case "awards": {
|
||||
return <Awards />;
|
||||
}
|
||||
case "certifications": {
|
||||
return <Certifications />;
|
||||
}
|
||||
case "skills": {
|
||||
return <Skills />;
|
||||
}
|
||||
case "interests": {
|
||||
return <Interests />;
|
||||
}
|
||||
case "publications": {
|
||||
return <Publications />;
|
||||
}
|
||||
case "volunteer": {
|
||||
return <Volunteer />;
|
||||
}
|
||||
case "languages": {
|
||||
return <Languages />;
|
||||
}
|
||||
case "projects": {
|
||||
return <Projects />;
|
||||
}
|
||||
case "references": {
|
||||
return <References />;
|
||||
}
|
||||
default: {
|
||||
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Rhyhorn = ({ columns, isFirstPage = false }: TemplateProps) => {
|
||||
const [main, sidebar] = columns;
|
||||
|
||||
return (
|
||||
<div className="p-custom space-y-4">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{main.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
|
||||
{sidebar.map((section) => (
|
||||
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
|
||||
export type TemplateProps = {
|
||||
columns: SectionKey[][];
|
||||
isFirstPage?: boolean;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
const { createGlobPatternsForDependencies } = require("@nx/react/tailwind");
|
||||
const { join } = require("path");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
join(__dirname, "{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"),
|
||||
...createGlobPatternsForDependencies(__dirname),
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
foreground: "var(--color-foreground)",
|
||||
primary: "var(--color-primary)",
|
||||
background: "var(--color-background)",
|
||||
},
|
||||
lineHeight: {
|
||||
tight: "calc(var(--line-height) - 0.5)",
|
||||
snug: "calc(var(--line-height) - 0.3)",
|
||||
normal: "var(--line-height)",
|
||||
relaxed: "calc(var(--line-height) + 0.3)",
|
||||
loose: "calc(var(--line-height) + 0.5)",
|
||||
},
|
||||
spacing: { custom: "var(--margin)" },
|
||||
typography: () => ({
|
||||
foreground: {
|
||||
css: {
|
||||
"--tw-prose-body": "var(--color-foreground)",
|
||||
"--tw-prose-headings": "var(--color-foreground)",
|
||||
"--tw-prose-lead": "var(--color-foreground)",
|
||||
"--tw-prose-links": "var(--color-foreground)",
|
||||
"--tw-prose-bold": "var(--color-foreground)",
|
||||
"--tw-prose-counters": "var(--color-foreground)",
|
||||
"--tw-prose-bullets": "var(--color-foreground)",
|
||||
"--tw-prose-hr": "var(--color-foreground)",
|
||||
"--tw-prose-quotes": "var(--color-foreground)",
|
||||
"--tw-prose-quote-borders": "var(--color-foreground)",
|
||||
"--tw-prose-captions": "var(--color-foreground)",
|
||||
"--tw-prose-code": "var(--color-foreground)",
|
||||
"--tw-prose-pre-code": "var(--color-foreground)",
|
||||
"--tw-prose-pre-bg": "var(--color-background)",
|
||||
"--tw-prose-th-borders": "var(--color-foreground)",
|
||||
"--tw-prose-td-borders": "var(--color-foreground)",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.test.jsx"
|
||||
],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/// <reference types='vitest' />
|
||||
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig, searchForWorkspaceRoot } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/artboard/",
|
||||
|
||||
cacheDir: "../../node_modules/.vite/artboard",
|
||||
|
||||
build: {
|
||||
sourcemap: true,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
server: {
|
||||
host: true,
|
||||
port: 6173,
|
||||
fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
|
||||
},
|
||||
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@/artboard/": `${searchForWorkspaceRoot(process.cwd())}/apps/artboard/src/`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
{
|
||||
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"extends": [
|
||||
"plugin:tailwindcss/recommended",
|
||||
"plugin:@tanstack/eslint-plugin-query/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"projectService": "./apps/client/tsconfig.json"
|
||||
},
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
"callees": ["cn", "clsx", "cva"],
|
||||
"config": "tailwind.config.js",
|
||||
"whitelist": ["ph", "ph\\-.*", "si", "si\\-.*"]
|
||||
}
|
||||
},
|
||||
"plugins": ["lingui"],
|
||||
"rules": {
|
||||
// eslint
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
// react
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/jsx-sort-props": [
|
||||
"error",
|
||||
{
|
||||
"reservedFirst": true,
|
||||
"callbacksLast": true,
|
||||
"shorthandFirst": true,
|
||||
"noSortAlphabetically": true
|
||||
}
|
||||
],
|
||||
|
||||
// react-hooks
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
|
||||
// lingui
|
||||
"lingui/no-unlocalized-strings": [
|
||||
2,
|
||||
{
|
||||
"ignore": [
|
||||
// Ignore strings which are a single "word" (no spaces)
|
||||
// and doesn't start with an uppercase letter
|
||||
"^(?![A-Z])\\S+$",
|
||||
// Ignore UPPERCASE literals
|
||||
// Example: const test = "FOO"
|
||||
"^[A-Z0-9_-]+$"
|
||||
],
|
||||
"ignoreNames": [
|
||||
// Ignore matching className (case-insensitive)
|
||||
{ "regex": { "pattern": "className", "flags": "i" } },
|
||||
// Ignore UPPERCASE names
|
||||
// Example: test.FOO = "ola!"
|
||||
{ "regex": { "pattern": "^[A-Z0-9_-]+$" } },
|
||||
"id",
|
||||
"src",
|
||||
"srcSet",
|
||||
"styleName",
|
||||
"placeholder",
|
||||
"alt",
|
||||
"type",
|
||||
"width",
|
||||
"height",
|
||||
"displayName",
|
||||
"Authorization"
|
||||
],
|
||||
"ignoreFunctions": [
|
||||
"cn",
|
||||
"cva",
|
||||
"track",
|
||||
"Error",
|
||||
"console.*",
|
||||
"*headers.set",
|
||||
"*.addEventListener",
|
||||
"*.removeEventListener",
|
||||
"*.postMessage",
|
||||
"*.getElementById",
|
||||
"*.dispatch",
|
||||
"*.commit",
|
||||
"*.includes",
|
||||
"*.indexOf",
|
||||
"*.endsWith",
|
||||
"*.startsWith",
|
||||
"require"
|
||||
],
|
||||
// Following settings require typed linting https://typescript-eslint.io/getting-started/typed-linting/
|
||||
"useTsTypes": true,
|
||||
"ignoreMethodsOnTypes": [
|
||||
// Ignore specified methods on Map and Set types
|
||||
"Map.get",
|
||||
"Map.has",
|
||||
"Set.has"
|
||||
]
|
||||
}
|
||||
],
|
||||
"lingui/t-call-in-function": 2,
|
||||
"lingui/no-single-variables-to-translate": 2,
|
||||
"lingui/no-expression-in-message": 2,
|
||||
"lingui/no-single-tag-to-translate": 2,
|
||||
"lingui/no-trans-inside-trans": 2,
|
||||
"lingui/text-restrictions": [
|
||||
2,
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"patterns": ["''", "\"", "’", "“"],
|
||||
"message": "This string contains forbidden characters"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US" translate="no">
|
||||
<head>
|
||||
<base href="/" />
|
||||
|
||||
<!-- SEO -->
|
||||
<title>Reactive Resume - A free and open-source resume builder</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
/>
|
||||
|
||||
<!-- Meta -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="googlebot" content="notranslate" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- White Flash Prevention Script -->
|
||||
<script type="text/javascript" src="/scripts/initialize-theme.js"></script>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/icon/dark.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/icon/light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/src/styles/main.css" />
|
||||
</head>
|
||||
<body class="bg-background text-sm text-foreground antialiased print:m-0 print:bg-white">
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
<!-- Phosphor Icons -->
|
||||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||
|
||||
<!-- Simple Icons -->
|
||||
<link
|
||||
type="text/css"
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/simple-icons-font@v14/font/simple-icons.min.css"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
const path = require("node:path");
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: { config: path.join(__dirname, "tailwind.config.js") },
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "client",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"mode": "development"
|
||||
},
|
||||
"production": {
|
||||
"mode": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:dev-server",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "client:build:development",
|
||||
"hmr": true
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "client:build:production",
|
||||
"hmr": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"executor": "@nx/vite:preview-server",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "client:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "client:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"passWithNoTests": true,
|
||||
"reportsDirectory": "../../coverage/apps/client"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.{ts,tsx,js,jsx}"]
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"executor": "@nx/web:file-server",
|
||||
"options": {
|
||||
"buildTarget": "client:build"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["frontend"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false
|
||||
},
|
||||
"/artboard": {
|
||||
"target": "http://localhost:6173",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 363 KiB |
@@ -1,33 +0,0 @@
|
||||
<svg width="498" height="151" viewBox="0 0 498 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_125)">
|
||||
<path
|
||||
d="M309.08 117.21C280.235 138.472 238.425 149.816 202.427 149.816C151.952 149.816 106.512 131.147 72.1348 100.098C69.4339 97.6559 71.8539 94.3284 75.095 96.2298C112.195 117.815 158.067 130.801 205.452 130.801C237.409 130.801 272.564 124.19 304.889 110.469C309.772 108.395 313.856 113.667 309.08 117.21Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M321.072 103.49C317.399 98.7795 296.699 101.264 287.408 102.366C284.578 102.712 284.146 100.249 286.695 98.477C303.182 86.8739 330.234 90.223 333.389 94.1123C336.543 98.0232 332.567 125.14 317.075 138.083C314.698 140.071 312.429 139.012 313.488 136.376C316.967 127.69 324.767 108.222 321.072 103.49Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M288.057 16.5637V5.28474C288.057 3.57776 289.353 2.43258 290.909 2.43258H341.405C343.025 2.43258 344.322 3.59937 344.322 5.28474V14.9432C344.3 16.5637 342.939 18.6813 340.519 22.0304L314.353 59.3894C324.076 59.1517 334.339 60.5994 343.155 65.5691C345.143 66.6926 345.683 68.3348 345.834 69.9554V81.9906C345.834 83.6328 344.019 85.5558 342.118 84.5619C326.582 76.4159 305.947 75.53 288.77 84.6483C287.019 85.599 285.183 83.6976 285.183 82.0554V70.6252C285.183 68.7886 285.204 65.6555 287.041 62.8682L317.356 19.3943H290.974C289.353 19.3943 288.057 18.2491 288.057 16.5637Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M103.854 86.9387H88.4916C87.0223 86.8307 85.8555 85.7287 85.7474 84.3242V5.47922C85.7474 3.90188 87.0655 2.64865 88.7076 2.64865H103.033C104.524 2.71343 105.713 3.85866 105.821 5.28474V15.5914H106.102C109.84 5.63045 116.862 0.984887 126.326 0.984887C135.941 0.984887 141.948 5.63045 146.269 15.5914C149.986 5.63045 158.434 0.984887 167.488 0.984887C173.927 0.984887 180.971 3.64258 185.271 9.60619C190.132 16.2396 189.138 25.8765 189.138 34.3249L189.117 84.0865C189.117 85.6639 187.799 86.9387 186.157 86.9387H170.815C169.281 86.8307 168.05 85.599 168.05 84.0865V42.298C168.05 38.9705 168.352 30.6733 167.617 27.5186C166.472 22.2249 163.037 20.7339 158.586 20.7339C154.869 20.7339 150.98 23.2188 149.403 27.1945C147.825 31.1703 147.976 37.8253 147.976 42.298V84.0865C147.976 85.6639 146.658 86.9387 145.016 86.9387H129.675C128.119 86.8307 126.909 85.599 126.909 84.0865L126.888 42.298C126.888 33.5039 128.335 20.5611 117.424 20.5611C106.382 20.5611 106.815 33.1797 106.815 42.298V84.0865C106.815 85.6639 105.496 86.9387 103.854 86.9387Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M387.796 0.984887C410.591 0.984887 422.929 20.5611 422.929 45.4527C422.929 69.5016 409.295 88.5808 387.796 88.5808C365.411 88.5808 353.224 69.0046 353.224 44.61C353.224 20.0641 365.562 0.984887 387.796 0.984887ZM387.925 17.0823C376.603 17.0823 375.89 32.5099 375.89 42.1252C375.89 51.762 375.739 72.3322 387.796 72.3322C399.701 72.3322 400.263 55.7378 400.263 45.6255C400.263 38.9705 399.982 31.019 397.973 24.7097C396.244 19.2215 392.809 17.0823 387.925 17.0823Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M452.488 86.9387H437.19C435.656 86.8307 434.424 85.599 434.424 84.0865L434.403 5.21991C434.532 3.77223 435.807 2.64865 437.363 2.64865H451.602C452.942 2.71343 454.044 3.62098 454.346 4.8526V16.9095H454.627C458.927 6.12742 464.955 0.984887 475.565 0.984887C482.457 0.984887 489.177 3.46973 493.499 10.276C497.518 16.5853 497.518 27.1945 497.518 34.8219V84.4539C497.345 85.8367 496.07 86.9387 494.557 86.9387H479.151C477.747 86.8307 476.58 85.7935 476.429 84.4539V41.6282C476.429 33.0069 477.423 20.3882 466.814 20.3882C463.076 20.3882 459.64 22.8947 457.933 26.6976C455.772 31.516 455.491 36.3128 455.491 41.6282V84.0865C455.47 85.6639 454.13 86.9387 452.488 86.9387Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M247.802 49.2772V45.9497C236.696 45.9497 224.963 48.3265 224.963 61.4205C224.963 68.0539 228.399 72.5482 234.297 72.5482C238.619 72.5482 242.487 69.8905 244.928 65.5691C247.953 60.2537 247.802 55.2624 247.802 49.2772ZM263.294 86.7226C262.279 87.6301 260.81 87.6949 259.664 87.0899C254.565 82.8549 253.658 80.8887 250.849 76.8481C242.422 85.4478 236.458 88.019 225.525 88.019C212.604 88.019 202.535 80.046 202.535 64.0782C202.535 51.6108 209.298 43.1191 218.913 38.9705C227.254 35.2973 238.9 34.649 247.802 33.6335V31.6456C247.802 27.994 248.083 23.6725 245.944 20.5179C244.064 17.6873 240.477 16.5205 237.323 16.5205C231.467 16.5205 226.238 19.5239 224.963 25.7468C224.704 27.1297 223.688 28.491 222.305 28.5558L207.396 26.9568C206.143 26.676 204.76 25.6604 205.106 23.7374C208.542 5.67368 224.855 0.228627 239.462 0.228627C246.938 0.228627 256.704 2.2165 262.603 7.87761C270.079 14.8568 269.366 24.1695 269.366 34.3033V58.2442C269.366 65.4394 272.348 68.5941 275.157 72.4834C276.151 73.8663 276.367 75.53 275.114 76.5672C271.981 79.1817 266.406 84.0433 263.338 86.7658L263.294 86.7226Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M46.4006 49.2772V45.9497C35.2944 45.9497 23.5617 48.3265 23.5617 61.4205C23.5617 68.0539 26.9972 72.5482 32.896 72.5482C37.2175 72.5482 41.0852 69.8905 43.5268 65.5691C46.5518 60.2537 46.4006 55.2624 46.4006 49.2772ZM61.893 86.7226C60.8775 87.6301 59.4081 87.6949 58.263 87.0899C53.1637 82.8549 52.2561 80.8887 49.4472 76.8481C41.0203 85.4478 35.0567 88.019 24.1234 88.019C11.2023 88.019 1.1333 80.046 1.1333 64.0782C1.1333 51.6108 7.89638 43.1191 17.5116 38.9705C25.852 35.2973 37.4984 34.649 46.4006 33.6335V31.6456C46.4006 27.994 46.6815 23.6725 44.5423 20.5179C42.6625 17.6873 39.0757 16.5205 35.921 16.5205C30.0655 16.5205 24.8365 19.5239 23.5617 25.7468C23.3024 27.1297 22.2868 28.491 20.904 28.5558L5.99494 26.9568C4.74172 26.676 3.35885 25.6604 3.70456 23.7374C7.14012 5.67368 23.4536 0.228627 38.0602 0.228627C45.5363 0.228627 55.3028 2.2165 61.2015 7.87761C68.6777 14.8568 67.9646 24.1695 67.9646 34.3033V58.2442C67.9646 65.4394 70.9464 68.5941 73.7554 72.4834C74.7493 73.8663 74.9654 75.53 73.7122 76.5672C70.5791 79.1817 65.0044 84.0433 61.9362 86.7658L61.893 86.7226Z"
|
||||
fill="#09090b" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_125">
|
||||
<rect width="496.978" height="150" fill="#09090b" transform="translate(0.833313 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,25 +0,0 @@
|
||||
<svg width="452" height="151" viewBox="0 0 452 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_106)">
|
||||
<path
|
||||
d="M193.468 78.927C193.468 100.308 176.742 116.063 156.215 116.063C135.689 116.063 118.962 100.308 118.962 78.927C118.962 57.3955 135.689 41.7911 156.215 41.7911C176.742 41.7911 193.468 57.3955 193.468 78.927ZM177.161 78.927C177.161 65.5661 167.467 56.4244 156.215 56.4244C144.964 56.4244 135.27 65.5661 135.27 78.927C135.27 92.1539 144.964 101.429 156.215 101.429C167.467 101.429 177.161 92.1371 177.161 78.927Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M273.835 78.927C273.835 100.308 257.108 116.063 236.582 116.063C216.055 116.063 199.328 100.308 199.328 78.927C199.328 57.4123 216.055 41.7911 236.582 41.7911C257.108 41.7911 273.835 57.3955 273.835 78.927ZM257.527 78.927C257.527 65.5661 247.833 56.4244 236.582 56.4244C225.33 56.4244 215.636 65.5661 215.636 78.927C215.636 92.1539 225.33 101.429 236.582 101.429C247.833 101.429 257.527 92.1371 257.527 78.927Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M350.852 44.0347V110.705C350.852 138.13 334.678 149.331 315.558 149.331C297.559 149.331 286.727 137.293 282.641 127.448L296.839 121.538C299.368 127.582 305.562 134.714 315.541 134.714C327.78 134.714 335.365 127.163 335.365 112.949V107.608H334.796C331.146 112.111 324.114 116.046 315.24 116.046C296.672 116.046 279.661 99.8723 279.661 79.0609C279.661 58.0987 296.672 41.7911 315.24 41.7911C324.097 41.7911 331.129 45.7257 334.796 50.0956H335.365V44.0514H350.852V44.0347ZM336.52 79.0609C336.52 65.9846 327.797 56.4244 316.696 56.4244C305.445 56.4244 296.019 65.9846 296.019 79.0609C296.019 92.0032 305.445 101.429 316.696 101.429C327.797 101.429 336.52 92.0032 336.52 79.0609Z"
|
||||
fill="#09090b" />
|
||||
<path d="M376.385 4.95665V113.786H360.479V4.95665H376.385Z" fill="#09090b" />
|
||||
<path
|
||||
d="M438.367 91.1493L451.025 99.5877C446.94 105.632 437.095 116.046 420.084 116.046C398.988 116.046 383.233 99.7384 383.233 78.9102C383.233 56.8263 399.122 41.7744 418.259 41.7744C437.53 41.7744 446.957 57.1109 450.037 65.3986L451.728 69.6179L402.085 90.1782C405.886 97.6288 411.796 101.429 420.084 101.429C428.389 101.429 434.148 97.3442 438.367 91.1493ZM399.407 77.7884L432.591 64.009C430.766 59.3712 425.274 56.1398 418.812 56.1398C410.524 56.1398 398.988 63.4565 399.407 77.7884Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M58.7548 69.2663V53.5112H111.847C112.366 56.257 112.634 59.5051 112.634 63.0211C112.634 74.8417 109.402 89.4583 98.9881 99.8724C88.8586 110.42 75.9163 116.046 58.7715 116.046C26.9935 116.046 0.271729 90.1615 0.271729 58.3834C0.271729 26.6053 26.9935 0.72068 58.7715 0.72068C76.3516 0.72068 88.8753 7.61877 98.2849 16.6097L87.1676 27.7271C80.4202 21.3982 71.2785 16.4758 58.7548 16.4758C35.5491 16.4758 17.3997 35.1776 17.3997 58.3834C17.3997 81.5891 35.5491 100.291 58.7548 100.291C73.8067 100.291 82.3791 94.2467 87.8708 88.755C92.3244 84.3014 95.2544 77.9391 96.4097 69.2495L58.7548 69.2663Z"
|
||||
fill="#09090b" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_106">
|
||||
<rect width="451.667" height="150" fill="#09090b" transform="translate(0.166702 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="500" height="151" viewBox="0 0 500 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_123)">
|
||||
<path
|
||||
d="M72.4341 56.5258C72.4341 65.0258 65.4341 72.0258 56.9341 72.0258C48.4341 72.0258 41.4341 65.0258 41.4341 56.5258C41.4341 48.0258 48.4341 41.0258 56.9341 41.0258C65.4341 41.0258 72.4341 48.0258 72.4341 56.5258ZM56.9341 78.0258C48.4341 78.0258 41.4341 85.0258 41.4341 93.5258C41.4341 102.026 48.4341 109.026 56.9341 109.026C65.4341 109.026 72.4341 102.026 72.4341 93.5258C72.4341 85.0258 65.4341 78.0258 56.9341 78.0258ZM150.434 75.0258C150.434 116.526 116.934 150.026 75.4341 150.026C33.9341 150.026 0.434082 116.526 0.434082 75.0258C0.434082 33.5258 33.9341 0.0258408 75.4341 0.0258408C116.934 0.0258408 150.434 33.5258 150.434 75.0258ZM130.434 75.0258C130.434 44.5258 105.934 20.0258 75.4341 20.0258C44.9341 20.0258 20.4341 44.5258 20.4341 75.0258C20.4341 105.526 44.9341 130.026 75.4341 130.026C105.934 130.026 130.434 105.526 130.434 75.0258ZM93.9341 78.0258C85.4341 78.0258 78.4341 85.0258 78.4341 93.5258C78.4341 102.026 85.4341 109.026 93.9341 109.026C102.434 109.026 109.434 102.026 109.434 93.5258C109.434 85.0258 102.434 78.0258 93.9341 78.0258ZM93.9341 41.0258C85.4341 41.0258 78.4341 48.0258 78.4341 56.5258C78.4341 65.0258 85.4341 72.0258 93.9341 72.0258C102.434 72.0258 109.434 65.0258 109.434 56.5258C109.434 48.0258 102.434 41.0258 93.9341 41.0258ZM351.934 29.5258C352.434 29.5258 352.934 30.0258 353.434 30.5258V46.5258C353.434 47.5258 352.434 48.0258 351.934 48.0258H325.434C324.434 48.0258 323.934 47.0258 323.934 46.5258V31.0258C323.934 30.0258 324.934 29.5258 325.434 29.5258H351.934ZM351.434 52.0258H300.434C299.934 52.0258 298.934 52.5258 298.934 53.5258L292.434 78.5258L291.934 80.0258L283.934 53.5258C283.934 53.0258 282.934 52.0258 282.434 52.0258H262.434C261.934 52.0258 260.934 52.5258 260.934 53.5258L253.434 78.5258L252.934 80.0258L252.434 78.5258L249.434 66.0258L246.434 53.5258C246.434 53.0258 245.434 52.0258 244.934 52.0258H204.934V30.5258C204.934 30.0258 203.934 29.0258 202.934 29.5258L177.934 37.5258C176.934 37.5258 176.434 38.0258 176.434 39.0258V52.5258H169.934C169.434 52.5258 168.434 53.0258 168.434 54.0258V73.0258C168.434 73.5258 168.934 74.5258 169.934 74.5258H176.434V98.0258C176.434 114.526 185.434 122.026 201.934 122.026C208.934 122.026 215.434 120.526 219.934 118.026V98.0258C219.934 97.0258 218.934 96.5258 218.434 97.0258C215.934 98.0258 213.434 98.5258 211.434 98.5258C206.934 98.5258 204.434 96.5258 204.434 91.5258V74.5258H218.934C219.434 74.5258 220.434 74.0258 220.434 73.0258V57.0258L239.434 120.026C239.434 120.526 240.434 121.526 240.934 121.526H261.934C262.434 121.526 263.434 121.026 263.434 120.026L272.434 92.0258L276.934 106.526L280.934 120.026C280.934 120.526 281.934 121.526 282.434 121.526H303.434C303.934 121.526 304.934 121.026 304.934 120.026L323.934 57.0258V120.026C323.934 120.526 324.434 121.526 325.434 121.526H350.934C351.434 121.526 352.434 121.026 352.434 120.026V53.5258C352.434 53.0258 351.934 52.0258 351.434 52.0258ZM384.934 29.5258H359.434C358.934 29.5258 357.934 30.0258 357.934 31.0258V119.526C357.934 120.026 358.434 121.026 359.434 121.026H384.934C385.434 121.026 386.434 120.526 386.434 119.526V30.5258C386.434 30.0258 385.934 29.5258 384.934 29.5258ZM418.934 29.5258H392.434C391.934 29.5258 390.934 30.0258 390.934 31.0258V46.5258C390.934 47.0258 391.434 48.0258 392.434 48.0258H418.934C419.434 48.0258 420.434 47.5258 420.434 46.5258V30.5258C420.434 30.0258 419.934 29.5258 418.934 29.5258ZM418.434 52.0258H392.934C392.434 52.0258 391.434 52.5258 391.434 53.5258V119.026C391.434 119.526 391.934 120.526 392.934 120.526H418.434C418.934 120.526 419.934 120.026 419.934 119.026V53.5258C419.934 53.0258 419.434 52.0258 418.434 52.0258ZM498.934 86.0258C498.934 105.026 482.934 121.526 460.434 121.526C438.434 121.526 422.434 105.026 422.434 86.0258C422.434 67.0258 438.434 50.5258 460.934 50.5258C482.934 50.5258 498.934 67.0258 498.934 86.0258ZM471.934 86.5258C471.934 79.5258 466.934 74.0258 460.934 74.5258C454.434 74.5258 449.934 80.0258 449.934 86.5258C449.934 93.0258 454.934 98.5258 460.934 98.5258C467.434 98.5258 471.934 93.0258 471.934 86.5258Z"
|
||||
fill="#09090b" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_123">
|
||||
<rect width="499" height="150" fill="#09090b" transform="translate(0.434082 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="781" height="151" viewBox="0 0 781 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M324.116 41.9481C318.271 38.7333 309.581 37.1694 297.548 37.1651C281.083 37.1712 265.8 40.5866 260.015 42.7425C258.709 43.1615 257.312 44.289 257.289 46.2185L257.293 46.644L258.913 54.8322L258.953 54.9846C259.461 56.6518 260.849 57.3995 262.114 57.3995H262.12C262.527 57.3821 262.844 57.3037 263.138 57.2286L265.879 56.5887C273.051 54.793 283.891 52.0811 295.996 52.0811C303.339 52.0811 308.275 52.721 311.584 54.1183C316.953 56.1512 316.987 59.6218 317.041 65.3668V73.9599C316.426 73.949 315.388 73.9381 313.789 73.9381C307.86 73.9381 296.203 74.2134 284.738 76.0515C267.242 78.7385 252.766 82.9262 252.857 107.032C252.864 137.632 281.246 137.69 294.886 137.718H294.888C304.147 137.716 312.69 136.349 318.269 135.195C331.002 132.238 334.253 128.195 334.082 115.543V64.6594C334.101 56.8989 334.124 47.2404 324.116 41.9481ZM317.126 120.552C310.349 122.752 302.934 123.865 295.081 123.865V124.58L295.072 123.865C282.524 123.769 270.27 122.334 270.254 106.329C270.335 93.599 277.127 92.0841 285.724 90.1644L286.321 90.0316C293.969 88.397 313.167 88.0901 317.128 88.0444L317.126 120.552ZM475.964 64.6594V115.543C476.134 128.195 472.883 132.238 460.151 135.195C454.571 136.349 446.028 137.716 436.768 137.718C423.129 137.69 394.745 137.632 394.738 107.032C394.647 82.9262 409.124 78.7385 426.619 76.0515C438.084 74.2134 449.742 73.9381 455.669 73.9381C457.269 73.9381 458.306 73.949 458.922 73.9599V65.3668C458.868 59.6218 458.834 56.1512 453.466 54.1183C450.156 52.721 445.22 52.0811 437.877 52.0811C425.771 52.0811 414.932 54.793 407.76 56.5887L405.02 57.2286C404.725 57.3038 404.408 57.3821 404 57.3995H403.995C402.73 57.3995 401.342 56.6518 400.834 54.9846L400.794 54.8322L399.174 46.644L399.17 46.2185C399.193 44.289 400.59 43.1615 401.896 42.7425C407.679 40.5866 422.964 37.1716 439.429 37.1651C451.462 37.1694 460.152 38.7333 465.997 41.9481C476.004 47.2404 475.982 56.8989 475.964 64.6594ZM459.009 88.0444C455.048 88.0901 435.85 88.397 428.201 90.0316L427.606 90.1644C419.008 92.0841 412.216 93.599 412.136 106.329C412.151 122.334 424.405 123.769 436.953 123.865L436.963 124.58V123.865C444.816 123.865 452.23 122.752 459.007 120.552L459.009 88.0444ZM732.972 37.1651C693.446 37.2183 690.094 63.1891 690.057 87.5318C690.094 111.785 693.446 137.665 732.972 137.718H732.975C772.471 137.665 775.838 111.855 775.893 87.5286C775.852 62.0737 772.501 37.2184 732.972 37.1651ZM732.974 123.51C709 123.405 707.795 111.099 707.632 87.5362C707.795 63.8443 708.998 51.476 732.971 51.3715C756.951 51.476 758.152 63.8443 758.315 87.5253C758.152 111.099 756.947 123.405 732.974 123.51ZM662.037 134.136L661.004 134.398C655.477 135.799 647.904 137.718 634.631 137.718H634.602C598.02 137.665 590.841 119.212 590.801 87.3544C590.845 50.8154 602.428 37.214 633.536 37.1651C644.511 37.1651 652.072 38.5842 657.497 39.8945L657.464 10.4641C657.419 9.17337 658.003 7.48327 661.058 6.90104L670.774 4.42302H671.304C673.536 4.47857 674.326 6.4679 674.331 8.15365V118.735C674.481 125.717 673.659 131.708 662.037 134.136ZM657.473 54.0574C654.229 53.2325 645.173 51.1941 634.976 51.1941C616.247 51.366 608.392 56.4538 608.196 87.5351C608.416 121.664 618.578 123.508 635.144 123.688L635.154 124.403V123.688C645.464 123.688 654.31 121.538 657.465 120.669L657.473 54.0574ZM570.156 135.413H561.466C559.138 135.409 557.383 133.651 557.377 131.324V65.7216C557.22 54.6244 553.904 51.538 541.954 51.3715C528.435 51.3715 514.751 54.7637 510.237 55.9934V131.322C510.233 133.69 508.589 135.41 506.327 135.413H497.461C495.132 135.409 493.374 133.651 493.371 131.324V57.7434C493.242 50.9329 493.882 46.225 503.57 43.0897C512.844 39.8869 530.595 37.1716 542.319 37.1651C564.666 37.1934 574.211 45.2554 574.241 64.1251V131.322C574.238 133.651 572.481 135.41 570.156 135.413ZM373.331 137.186H373.338C378.332 137.088 382.026 136.101 383.472 134.477C383.986 133.901 384.223 133.236 384.159 132.58C384.125 132.04 384.125 132.04 382.755 125.623L382.723 125.469C382.426 123.791 381.228 123.028 380.241 123.028C380.149 123.028 380.059 123.034 380.107 123.04C380.107 123.04 378.853 122.979 377.614 122.979H377.602C374.043 122.866 371.092 122.348 371.031 117.139V8.15478C371.028 6.33731 369.932 4.47308 367.828 4.42302L367.208 4.42825L357.475 6.92259C355.817 7.09236 354.091 8.27097 354.165 10.4617V117.494C354.183 130.169 360.986 137.163 373.323 137.186L373.331 137.186ZM235.87 135.413H166.139C163.811 135.409 162.054 133.651 162.049 131.324V122.812C162.001 120.726 162.67 119.796 163.955 118.204L219.457 53.6776H165.076C162.747 53.6723 160.989 51.9145 160.986 49.5889V43.5598C160.99 41.2331 162.747 39.4755 165.075 39.4711H235.515C237.842 39.4755 239.599 41.2331 239.604 43.5587V52.2476C239.637 53.818 239.093 55.15 237.839 56.5474L182.197 121.204H235.869C238.196 121.211 239.954 122.967 239.958 125.294V131.322C239.954 133.651 238.196 135.41 235.87 135.413Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M22.3275 28.7189C10.2759 28.7189 4.07923 61.6253 4.72458 92.5194C4.7073 92.5151 4.68744 92.5238 4.67017 92.5194C5.31443 119.636 11.3109 145.628 23.1982 145.628C68.8157 145.628 120.79 102.608 120.79 87.1868C120.79 83.3289 116.86 75.9057 111.594 70.0464C111.587 70.0504 111.573 70.0424 111.567 70.0464C111.209 69.6307 110.829 69.216 110.451 68.7949C94.2009 50.6641 60.4881 28.7189 22.3275 28.7189Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M4.71592 92.5183C38.9903 100.567 75.5815 91.5062 111.031 70.3783C111.21 70.2717 111.388 70.165 111.567 70.0573C111.209 69.6416 110.842 69.2226 110.464 68.8014C94.214 50.6706 60.4806 28.7211 22.32 28.7211C10.2683 28.7211 4.07056 61.6242 4.71592 92.5183ZM111.567 70.0573C111.575 70.0538 111.581 70.0495 111.587 70.0454L111.567 70.0573Z"
|
||||
fill="#09090b" />
|
||||
<path
|
||||
d="M4.68018 92.5107C5.32444 119.627 11.323 145.629 23.2103 145.629C68.8279 145.629 120.798 102.621 120.798 87.1999C120.798 83.3419 116.854 75.9046 111.588 70.0453C75.9539 91.4181 39.1461 100.616 4.68018 92.5107Z"
|
||||
fill="#09090b" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,33 +0,0 @@
|
||||
<svg width="498" height="151" viewBox="0 0 498 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_125)">
|
||||
<path
|
||||
d="M309.08 117.21C280.235 138.472 238.425 149.816 202.427 149.816C151.952 149.816 106.512 131.147 72.1348 100.098C69.4339 97.6559 71.8539 94.3284 75.095 96.2298C112.195 117.815 158.067 130.801 205.452 130.801C237.409 130.801 272.564 124.19 304.889 110.469C309.772 108.395 313.856 113.667 309.08 117.21Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M321.072 103.49C317.399 98.7795 296.699 101.264 287.408 102.366C284.578 102.712 284.146 100.249 286.695 98.477C303.182 86.8739 330.234 90.223 333.389 94.1123C336.543 98.0232 332.567 125.14 317.075 138.083C314.698 140.071 312.429 139.012 313.488 136.376C316.967 127.69 324.767 108.222 321.072 103.49Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M288.057 16.5637V5.28474C288.057 3.57776 289.353 2.43258 290.909 2.43258H341.405C343.025 2.43258 344.322 3.59937 344.322 5.28474V14.9432C344.3 16.5637 342.939 18.6813 340.519 22.0304L314.353 59.3894C324.076 59.1517 334.339 60.5994 343.155 65.5691C345.143 66.6926 345.683 68.3348 345.834 69.9554V81.9906C345.834 83.6328 344.019 85.5558 342.118 84.5619C326.582 76.4159 305.947 75.53 288.77 84.6483C287.019 85.599 285.183 83.6976 285.183 82.0554V70.6252C285.183 68.7886 285.204 65.6555 287.041 62.8682L317.356 19.3943H290.974C289.353 19.3943 288.057 18.2491 288.057 16.5637Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M103.854 86.9387H88.4916C87.0223 86.8307 85.8555 85.7287 85.7474 84.3242V5.47922C85.7474 3.90188 87.0655 2.64865 88.7076 2.64865H103.033C104.524 2.71343 105.713 3.85866 105.821 5.28474V15.5914H106.102C109.84 5.63045 116.862 0.984887 126.326 0.984887C135.941 0.984887 141.948 5.63045 146.269 15.5914C149.986 5.63045 158.434 0.984887 167.488 0.984887C173.927 0.984887 180.971 3.64258 185.271 9.60619C190.132 16.2396 189.138 25.8765 189.138 34.3249L189.117 84.0865C189.117 85.6639 187.799 86.9387 186.157 86.9387H170.815C169.281 86.8307 168.05 85.599 168.05 84.0865V42.298C168.05 38.9705 168.352 30.6733 167.617 27.5186C166.472 22.2249 163.037 20.7339 158.586 20.7339C154.869 20.7339 150.98 23.2188 149.403 27.1945C147.825 31.1703 147.976 37.8253 147.976 42.298V84.0865C147.976 85.6639 146.658 86.9387 145.016 86.9387H129.675C128.119 86.8307 126.909 85.599 126.909 84.0865L126.888 42.298C126.888 33.5039 128.335 20.5611 117.424 20.5611C106.382 20.5611 106.815 33.1797 106.815 42.298V84.0865C106.815 85.6639 105.496 86.9387 103.854 86.9387Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M387.796 0.984887C410.591 0.984887 422.929 20.5611 422.929 45.4527C422.929 69.5016 409.295 88.5808 387.796 88.5808C365.411 88.5808 353.224 69.0046 353.224 44.61C353.224 20.0641 365.562 0.984887 387.796 0.984887ZM387.925 17.0823C376.603 17.0823 375.89 32.5099 375.89 42.1252C375.89 51.762 375.739 72.3322 387.796 72.3322C399.701 72.3322 400.263 55.7378 400.263 45.6255C400.263 38.9705 399.982 31.019 397.973 24.7097C396.244 19.2215 392.809 17.0823 387.925 17.0823Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M452.488 86.9387H437.19C435.656 86.8307 434.424 85.599 434.424 84.0865L434.403 5.21991C434.532 3.77223 435.807 2.64865 437.363 2.64865H451.602C452.942 2.71343 454.044 3.62098 454.346 4.8526V16.9095H454.627C458.927 6.12742 464.955 0.984887 475.565 0.984887C482.457 0.984887 489.177 3.46973 493.499 10.276C497.518 16.5853 497.518 27.1945 497.518 34.8219V84.4539C497.345 85.8367 496.07 86.9387 494.557 86.9387H479.151C477.747 86.8307 476.58 85.7935 476.429 84.4539V41.6282C476.429 33.0069 477.423 20.3882 466.814 20.3882C463.076 20.3882 459.64 22.8947 457.933 26.6976C455.772 31.516 455.491 36.3128 455.491 41.6282V84.0865C455.47 85.6639 454.13 86.9387 452.488 86.9387Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M247.802 49.2772V45.9497C236.696 45.9497 224.963 48.3265 224.963 61.4205C224.963 68.0539 228.399 72.5482 234.297 72.5482C238.619 72.5482 242.487 69.8905 244.928 65.5691C247.953 60.2537 247.802 55.2624 247.802 49.2772ZM263.294 86.7226C262.279 87.6301 260.81 87.6949 259.664 87.0899C254.565 82.8549 253.658 80.8887 250.849 76.8481C242.422 85.4478 236.458 88.019 225.525 88.019C212.604 88.019 202.535 80.046 202.535 64.0782C202.535 51.6108 209.298 43.1191 218.913 38.9705C227.254 35.2973 238.9 34.649 247.802 33.6335V31.6456C247.802 27.994 248.083 23.6725 245.944 20.5179C244.064 17.6873 240.477 16.5205 237.323 16.5205C231.467 16.5205 226.238 19.5239 224.963 25.7468C224.704 27.1297 223.688 28.491 222.305 28.5558L207.396 26.9568C206.143 26.676 204.76 25.6604 205.106 23.7374C208.542 5.67368 224.855 0.228627 239.462 0.228627C246.938 0.228627 256.704 2.2165 262.603 7.87761C270.079 14.8568 269.366 24.1695 269.366 34.3033V58.2442C269.366 65.4394 272.348 68.5941 275.157 72.4834C276.151 73.8663 276.367 75.53 275.114 76.5672C271.981 79.1817 266.406 84.0433 263.338 86.7658L263.294 86.7226Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M46.4006 49.2772V45.9497C35.2944 45.9497 23.5617 48.3265 23.5617 61.4205C23.5617 68.0539 26.9972 72.5482 32.896 72.5482C37.2175 72.5482 41.0852 69.8905 43.5268 65.5691C46.5518 60.2537 46.4006 55.2624 46.4006 49.2772ZM61.893 86.7226C60.8775 87.6301 59.4081 87.6949 58.263 87.0899C53.1637 82.8549 52.2561 80.8887 49.4472 76.8481C41.0203 85.4478 35.0567 88.019 24.1234 88.019C11.2023 88.019 1.1333 80.046 1.1333 64.0782C1.1333 51.6108 7.89638 43.1191 17.5116 38.9705C25.852 35.2973 37.4984 34.649 46.4006 33.6335V31.6456C46.4006 27.994 46.6815 23.6725 44.5423 20.5179C42.6625 17.6873 39.0757 16.5205 35.921 16.5205C30.0655 16.5205 24.8365 19.5239 23.5617 25.7468C23.3024 27.1297 22.2868 28.491 20.904 28.5558L5.99494 26.9568C4.74172 26.676 3.35885 25.6604 3.70456 23.7374C7.14012 5.67368 23.4536 0.228627 38.0602 0.228627C45.5363 0.228627 55.3028 2.2165 61.2015 7.87761C68.6777 14.8568 67.9646 24.1695 67.9646 34.3033V58.2442C67.9646 65.4394 70.9464 68.5941 73.7554 72.4834C74.7493 73.8663 74.9654 75.53 73.7122 76.5672C70.5791 79.1817 65.0044 84.0433 61.9362 86.7658L61.893 86.7226Z"
|
||||
fill="#fafafa" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_125">
|
||||
<rect width="496.978" height="150" fill="#fafafa" transform="translate(0.833313 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,25 +0,0 @@
|
||||
<svg width="452" height="151" viewBox="0 0 452 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_106)">
|
||||
<path
|
||||
d="M193.468 78.927C193.468 100.308 176.742 116.063 156.215 116.063C135.689 116.063 118.962 100.308 118.962 78.927C118.962 57.3955 135.689 41.7911 156.215 41.7911C176.742 41.7911 193.468 57.3955 193.468 78.927ZM177.161 78.927C177.161 65.5661 167.467 56.4244 156.215 56.4244C144.964 56.4244 135.27 65.5661 135.27 78.927C135.27 92.1539 144.964 101.429 156.215 101.429C167.467 101.429 177.161 92.1371 177.161 78.927Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M273.835 78.927C273.835 100.308 257.108 116.063 236.582 116.063C216.055 116.063 199.328 100.308 199.328 78.927C199.328 57.4123 216.055 41.7911 236.582 41.7911C257.108 41.7911 273.835 57.3955 273.835 78.927ZM257.527 78.927C257.527 65.5661 247.833 56.4244 236.582 56.4244C225.33 56.4244 215.636 65.5661 215.636 78.927C215.636 92.1539 225.33 101.429 236.582 101.429C247.833 101.429 257.527 92.1371 257.527 78.927Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M350.852 44.0347V110.705C350.852 138.13 334.678 149.331 315.558 149.331C297.559 149.331 286.727 137.293 282.641 127.448L296.839 121.538C299.368 127.582 305.562 134.714 315.541 134.714C327.78 134.714 335.365 127.163 335.365 112.949V107.608H334.796C331.146 112.111 324.114 116.046 315.24 116.046C296.672 116.046 279.661 99.8723 279.661 79.0609C279.661 58.0987 296.672 41.7911 315.24 41.7911C324.097 41.7911 331.129 45.7257 334.796 50.0956H335.365V44.0514H350.852V44.0347ZM336.52 79.0609C336.52 65.9846 327.797 56.4244 316.696 56.4244C305.445 56.4244 296.019 65.9846 296.019 79.0609C296.019 92.0032 305.445 101.429 316.696 101.429C327.797 101.429 336.52 92.0032 336.52 79.0609Z"
|
||||
fill="#fafafa" />
|
||||
<path d="M376.385 4.95665V113.786H360.479V4.95665H376.385Z" fill="#fafafa" />
|
||||
<path
|
||||
d="M438.367 91.1493L451.025 99.5877C446.94 105.632 437.095 116.046 420.084 116.046C398.988 116.046 383.233 99.7384 383.233 78.9102C383.233 56.8263 399.122 41.7744 418.259 41.7744C437.53 41.7744 446.957 57.1109 450.037 65.3986L451.728 69.6179L402.085 90.1782C405.886 97.6288 411.796 101.429 420.084 101.429C428.389 101.429 434.148 97.3442 438.367 91.1493ZM399.407 77.7884L432.591 64.009C430.766 59.3712 425.274 56.1398 418.812 56.1398C410.524 56.1398 398.988 63.4565 399.407 77.7884Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M58.7548 69.2663V53.5112H111.847C112.366 56.257 112.634 59.5051 112.634 63.0211C112.634 74.8417 109.402 89.4583 98.9881 99.8724C88.8586 110.42 75.9163 116.046 58.7715 116.046C26.9935 116.046 0.271729 90.1615 0.271729 58.3834C0.271729 26.6053 26.9935 0.72068 58.7715 0.72068C76.3516 0.72068 88.8753 7.61877 98.2849 16.6097L87.1676 27.7271C80.4202 21.3982 71.2785 16.4758 58.7548 16.4758C35.5491 16.4758 17.3997 35.1776 17.3997 58.3834C17.3997 81.5891 35.5491 100.291 58.7548 100.291C73.8067 100.291 82.3791 94.2467 87.8708 88.755C92.3244 84.3014 95.2544 77.9391 96.4097 69.2495L58.7548 69.2663Z"
|
||||
fill="#fafafa" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_106">
|
||||
<rect width="451.667" height="150" fill="#fafafa" transform="translate(0.166702 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="500" height="151" viewBox="0 0 500 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_123)">
|
||||
<path
|
||||
d="M72.4341 56.5258C72.4341 65.0258 65.4341 72.0258 56.9341 72.0258C48.4341 72.0258 41.4341 65.0258 41.4341 56.5258C41.4341 48.0258 48.4341 41.0258 56.9341 41.0258C65.4341 41.0258 72.4341 48.0258 72.4341 56.5258ZM56.9341 78.0258C48.4341 78.0258 41.4341 85.0258 41.4341 93.5258C41.4341 102.026 48.4341 109.026 56.9341 109.026C65.4341 109.026 72.4341 102.026 72.4341 93.5258C72.4341 85.0258 65.4341 78.0258 56.9341 78.0258ZM150.434 75.0258C150.434 116.526 116.934 150.026 75.4341 150.026C33.9341 150.026 0.434082 116.526 0.434082 75.0258C0.434082 33.5258 33.9341 0.0258408 75.4341 0.0258408C116.934 0.0258408 150.434 33.5258 150.434 75.0258ZM130.434 75.0258C130.434 44.5258 105.934 20.0258 75.4341 20.0258C44.9341 20.0258 20.4341 44.5258 20.4341 75.0258C20.4341 105.526 44.9341 130.026 75.4341 130.026C105.934 130.026 130.434 105.526 130.434 75.0258ZM93.9341 78.0258C85.4341 78.0258 78.4341 85.0258 78.4341 93.5258C78.4341 102.026 85.4341 109.026 93.9341 109.026C102.434 109.026 109.434 102.026 109.434 93.5258C109.434 85.0258 102.434 78.0258 93.9341 78.0258ZM93.9341 41.0258C85.4341 41.0258 78.4341 48.0258 78.4341 56.5258C78.4341 65.0258 85.4341 72.0258 93.9341 72.0258C102.434 72.0258 109.434 65.0258 109.434 56.5258C109.434 48.0258 102.434 41.0258 93.9341 41.0258ZM351.934 29.5258C352.434 29.5258 352.934 30.0258 353.434 30.5258V46.5258C353.434 47.5258 352.434 48.0258 351.934 48.0258H325.434C324.434 48.0258 323.934 47.0258 323.934 46.5258V31.0258C323.934 30.0258 324.934 29.5258 325.434 29.5258H351.934ZM351.434 52.0258H300.434C299.934 52.0258 298.934 52.5258 298.934 53.5258L292.434 78.5258L291.934 80.0258L283.934 53.5258C283.934 53.0258 282.934 52.0258 282.434 52.0258H262.434C261.934 52.0258 260.934 52.5258 260.934 53.5258L253.434 78.5258L252.934 80.0258L252.434 78.5258L249.434 66.0258L246.434 53.5258C246.434 53.0258 245.434 52.0258 244.934 52.0258H204.934V30.5258C204.934 30.0258 203.934 29.0258 202.934 29.5258L177.934 37.5258C176.934 37.5258 176.434 38.0258 176.434 39.0258V52.5258H169.934C169.434 52.5258 168.434 53.0258 168.434 54.0258V73.0258C168.434 73.5258 168.934 74.5258 169.934 74.5258H176.434V98.0258C176.434 114.526 185.434 122.026 201.934 122.026C208.934 122.026 215.434 120.526 219.934 118.026V98.0258C219.934 97.0258 218.934 96.5258 218.434 97.0258C215.934 98.0258 213.434 98.5258 211.434 98.5258C206.934 98.5258 204.434 96.5258 204.434 91.5258V74.5258H218.934C219.434 74.5258 220.434 74.0258 220.434 73.0258V57.0258L239.434 120.026C239.434 120.526 240.434 121.526 240.934 121.526H261.934C262.434 121.526 263.434 121.026 263.434 120.026L272.434 92.0258L276.934 106.526L280.934 120.026C280.934 120.526 281.934 121.526 282.434 121.526H303.434C303.934 121.526 304.934 121.026 304.934 120.026L323.934 57.0258V120.026C323.934 120.526 324.434 121.526 325.434 121.526H350.934C351.434 121.526 352.434 121.026 352.434 120.026V53.5258C352.434 53.0258 351.934 52.0258 351.434 52.0258ZM384.934 29.5258H359.434C358.934 29.5258 357.934 30.0258 357.934 31.0258V119.526C357.934 120.026 358.434 121.026 359.434 121.026H384.934C385.434 121.026 386.434 120.526 386.434 119.526V30.5258C386.434 30.0258 385.934 29.5258 384.934 29.5258ZM418.934 29.5258H392.434C391.934 29.5258 390.934 30.0258 390.934 31.0258V46.5258C390.934 47.0258 391.434 48.0258 392.434 48.0258H418.934C419.434 48.0258 420.434 47.5258 420.434 46.5258V30.5258C420.434 30.0258 419.934 29.5258 418.934 29.5258ZM418.434 52.0258H392.934C392.434 52.0258 391.434 52.5258 391.434 53.5258V119.026C391.434 119.526 391.934 120.526 392.934 120.526H418.434C418.934 120.526 419.934 120.026 419.934 119.026V53.5258C419.934 53.0258 419.434 52.0258 418.434 52.0258ZM498.934 86.0258C498.934 105.026 482.934 121.526 460.434 121.526C438.434 121.526 422.434 105.026 422.434 86.0258C422.434 67.0258 438.434 50.5258 460.934 50.5258C482.934 50.5258 498.934 67.0258 498.934 86.0258ZM471.934 86.5258C471.934 79.5258 466.934 74.0258 460.934 74.5258C454.434 74.5258 449.934 80.0258 449.934 86.5258C449.934 93.0258 454.934 98.5258 460.934 98.5258C467.434 98.5258 471.934 93.0258 471.934 86.5258Z"
|
||||
fill="#fafafa" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_123">
|
||||
<rect width="499" height="150" fill="#fafafa" transform="translate(0.434082 0.0258408)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="781" height="151" viewBox="0 0 781 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M324.116 41.9481C318.271 38.7333 309.581 37.1694 297.548 37.1651C281.083 37.1712 265.8 40.5866 260.015 42.7425C258.709 43.1615 257.312 44.289 257.289 46.2185L257.293 46.644L258.913 54.8322L258.953 54.9846C259.461 56.6518 260.849 57.3995 262.114 57.3995H262.12C262.527 57.3821 262.844 57.3037 263.138 57.2286L265.879 56.5887C273.051 54.793 283.891 52.0811 295.996 52.0811C303.339 52.0811 308.275 52.721 311.584 54.1183C316.953 56.1512 316.987 59.6218 317.041 65.3668V73.9599C316.426 73.949 315.388 73.9381 313.789 73.9381C307.86 73.9381 296.203 74.2134 284.738 76.0515C267.242 78.7385 252.766 82.9262 252.857 107.032C252.864 137.632 281.246 137.69 294.886 137.718H294.888C304.147 137.716 312.69 136.349 318.269 135.195C331.002 132.238 334.253 128.195 334.082 115.543V64.6594C334.101 56.8989 334.124 47.2404 324.116 41.9481ZM317.126 120.552C310.349 122.752 302.934 123.865 295.081 123.865V124.58L295.072 123.865C282.524 123.769 270.27 122.334 270.254 106.329C270.335 93.599 277.127 92.0841 285.724 90.1644L286.321 90.0316C293.969 88.397 313.167 88.0901 317.128 88.0444L317.126 120.552ZM475.964 64.6594V115.543C476.134 128.195 472.883 132.238 460.151 135.195C454.571 136.349 446.028 137.716 436.768 137.718C423.129 137.69 394.745 137.632 394.738 107.032C394.647 82.9262 409.124 78.7385 426.619 76.0515C438.084 74.2134 449.742 73.9381 455.669 73.9381C457.269 73.9381 458.306 73.949 458.922 73.9599V65.3668C458.868 59.6218 458.834 56.1512 453.466 54.1183C450.156 52.721 445.22 52.0811 437.877 52.0811C425.771 52.0811 414.932 54.793 407.76 56.5887L405.02 57.2286C404.725 57.3038 404.408 57.3821 404 57.3995H403.995C402.73 57.3995 401.342 56.6518 400.834 54.9846L400.794 54.8322L399.174 46.644L399.17 46.2185C399.193 44.289 400.59 43.1615 401.896 42.7425C407.679 40.5866 422.964 37.1716 439.429 37.1651C451.462 37.1694 460.152 38.7333 465.997 41.9481C476.004 47.2404 475.982 56.8989 475.964 64.6594ZM459.009 88.0444C455.048 88.0901 435.85 88.397 428.201 90.0316L427.606 90.1644C419.008 92.0841 412.216 93.599 412.136 106.329C412.151 122.334 424.405 123.769 436.953 123.865L436.963 124.58V123.865C444.816 123.865 452.23 122.752 459.007 120.552L459.009 88.0444ZM732.972 37.1651C693.446 37.2183 690.094 63.1891 690.057 87.5318C690.094 111.785 693.446 137.665 732.972 137.718H732.975C772.471 137.665 775.838 111.855 775.893 87.5286C775.852 62.0737 772.501 37.2184 732.972 37.1651ZM732.974 123.51C709 123.405 707.795 111.099 707.632 87.5362C707.795 63.8443 708.998 51.476 732.971 51.3715C756.951 51.476 758.152 63.8443 758.315 87.5253C758.152 111.099 756.947 123.405 732.974 123.51ZM662.037 134.136L661.004 134.398C655.477 135.799 647.904 137.718 634.631 137.718H634.602C598.02 137.665 590.841 119.212 590.801 87.3544C590.845 50.8154 602.428 37.214 633.536 37.1651C644.511 37.1651 652.072 38.5842 657.497 39.8945L657.464 10.4641C657.419 9.17337 658.003 7.48327 661.058 6.90104L670.774 4.42302H671.304C673.536 4.47857 674.326 6.4679 674.331 8.15365V118.735C674.481 125.717 673.659 131.708 662.037 134.136ZM657.473 54.0574C654.229 53.2325 645.173 51.1941 634.976 51.1941C616.247 51.366 608.392 56.4538 608.196 87.5351C608.416 121.664 618.578 123.508 635.144 123.688L635.154 124.403V123.688C645.464 123.688 654.31 121.538 657.465 120.669L657.473 54.0574ZM570.156 135.413H561.466C559.138 135.409 557.383 133.651 557.377 131.324V65.7216C557.22 54.6244 553.904 51.538 541.954 51.3715C528.435 51.3715 514.751 54.7637 510.237 55.9934V131.322C510.233 133.69 508.589 135.41 506.327 135.413H497.461C495.132 135.409 493.374 133.651 493.371 131.324V57.7434C493.242 50.9329 493.882 46.225 503.57 43.0897C512.844 39.8869 530.595 37.1716 542.319 37.1651C564.666 37.1934 574.211 45.2554 574.241 64.1251V131.322C574.238 133.651 572.481 135.41 570.156 135.413ZM373.331 137.186H373.338C378.332 137.088 382.026 136.101 383.472 134.477C383.986 133.901 384.223 133.236 384.159 132.58C384.125 132.04 384.125 132.04 382.755 125.623L382.723 125.469C382.426 123.791 381.228 123.028 380.241 123.028C380.149 123.028 380.059 123.034 380.107 123.04C380.107 123.04 378.853 122.979 377.614 122.979H377.602C374.043 122.866 371.092 122.348 371.031 117.139V8.15478C371.028 6.33731 369.932 4.47308 367.828 4.42302L367.208 4.42825L357.475 6.92259C355.817 7.09236 354.091 8.27097 354.165 10.4617V117.494C354.183 130.169 360.986 137.163 373.323 137.186L373.331 137.186ZM235.87 135.413H166.139C163.811 135.409 162.054 133.651 162.049 131.324V122.812C162.001 120.726 162.67 119.796 163.955 118.204L219.457 53.6776H165.076C162.747 53.6723 160.989 51.9145 160.986 49.5889V43.5598C160.99 41.2331 162.747 39.4755 165.075 39.4711H235.515C237.842 39.4755 239.599 41.2331 239.604 43.5587V52.2476C239.637 53.818 239.093 55.15 237.839 56.5474L182.197 121.204H235.869C238.196 121.211 239.954 122.967 239.958 125.294V131.322C239.954 133.651 238.196 135.41 235.87 135.413Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M22.3275 28.7189C10.2759 28.7189 4.07923 61.6253 4.72458 92.5194C4.7073 92.5151 4.68744 92.5238 4.67017 92.5194C5.31443 119.636 11.3109 145.628 23.1982 145.628C68.8157 145.628 120.79 102.608 120.79 87.1868C120.79 83.3289 116.86 75.9057 111.594 70.0464C111.587 70.0504 111.573 70.0424 111.567 70.0464C111.209 69.6307 110.829 69.216 110.451 68.7949C94.2009 50.6641 60.4881 28.7189 22.3275 28.7189Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M4.71592 92.5183C38.9903 100.567 75.5815 91.5062 111.031 70.3783C111.21 70.2717 111.388 70.165 111.567 70.0573C111.209 69.6416 110.842 69.2226 110.464 68.8014C94.214 50.6706 60.4806 28.7211 22.32 28.7211C10.2683 28.7211 4.07056 61.6242 4.71592 92.5183ZM111.567 70.0573C111.575 70.0538 111.581 70.0495 111.587 70.0454L111.567 70.0573Z"
|
||||
fill="#fafafa" />
|
||||
<path
|
||||
d="M4.68018 92.5107C5.32444 119.627 11.323 145.629 23.2103 145.629C68.8279 145.629 120.798 102.621 120.798 87.1999C120.798 83.3419 116.854 75.9046 111.588 70.0453C75.9539 91.4181 39.1461 100.616 4.68018 92.5107Z"
|
||||
fill="#fafafa" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
|
||||
fill="#09090B" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2518 137.634 61.6569 137.614C75.0665 136.968 85.1471 135.549 96.3849 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7678 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
|
||||
fill="#09090B" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
|
||||
fill="#FAFAFA" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
|
||||
fill="#FAFAFA" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 867 KiB |
|
Before Width: | Height: | Size: 910 KiB |
@@ -1,15 +0,0 @@
|
||||
(function initializeTheme() {
|
||||
try {
|
||||
if (
|
||||
localStorage.theme === "dark" ||
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
})();
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg width="730" height="151" viewBox="0 0 730 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_450_81)">
|
||||
<path d="M188.89 81.5672C188.89 92.7163 192.303 101.654 199.123 108.394C205.95 115.127 215.173 118.497 226.807 118.497C238.442 118.497 246.271 115.65 252.088 109.963C257.905 104.233 260.814 97.5844 260.814 90.0317V89.3461H243.981V89.9607C243.981 93.0061 242.651 95.6271 239.999 97.8102C237.339 99.9933 232.902 101.089 226.672 101.089C219.668 101.089 214.533 99.3787 211.284 95.9664C208.033 92.5537 206.405 88.0038 206.405 82.3164V78.6284C206.405 72.9406 208.055 68.3907 211.355 64.9784C214.654 61.5657 219.76 59.8561 226.672 59.8561C232.717 59.8561 237.112 60.9017 239.864 62.9932C242.659 65.0842 244.053 67.7267 244.053 70.9132V71.5983H260.885L260.814 70.708C260.536 63.1059 257.564 56.5144 251.882 50.9113C246.2 45.2664 237.844 42.4472 226.807 42.4472C215.173 42.4472 205.943 45.8173 199.123 52.5507C192.296 59.2415 188.89 68.1787 188.89 79.3772V81.5603V81.5672Z" fill="#263238"/>
|
||||
<path d="M274.972 116.448H292.146V80.9948C292.146 73.2304 295.901 69.2173 299.015 67.0269C302.13 64.7944 305.679 63.6852 309.661 63.6852C310.898 63.6852 312.022 63.7557 313.025 63.8899C314.077 64.0245 315.066 64.1866 315.976 64.3707V43.4858C315.521 43.3022 314.717 43.0971 313.572 42.8712C312.47 42.645 311.396 42.5319 310.344 42.5319C306.269 42.5319 302.628 43.6269 299.421 45.81C296.533 47.7742 294.201 50.0988 292.43 52.7623C292.245 53.038 291.812 52.9249 291.79 52.5929L290.29 44.5103H274.965V116.448H274.972Z" fill="#263238"/>
|
||||
<path d="M322.995 81.5672C322.995 92.7163 326.543 101.654 333.64 108.394C340.737 115.127 350.081 118.497 361.673 118.497C373.264 118.497 382.537 115.127 389.634 108.394C396.781 101.661 400.351 92.7163 400.351 81.5672V79.3841C400.351 68.1929 396.781 59.2484 389.634 52.5576C382.537 45.8242 373.215 42.4541 361.673 42.4541C350.131 42.4541 340.744 45.8242 333.64 52.5576C326.543 59.2484 322.995 68.1856 322.995 79.3841V81.5672ZM340.51 78.6353C340.51 72.4038 342.344 67.8536 346.007 64.9853C349.669 62.1167 354.889 60.6823 361.673 60.6823C368.457 60.6823 373.606 62.1167 377.268 64.9853C380.98 67.8536 382.836 72.4038 382.836 78.6353V82.3233C382.836 88.7879 381.001 93.4014 377.339 96.178C373.719 98.9052 368.5 100.276 361.673 100.276C354.846 100.276 349.605 98.9125 345.943 96.178C342.323 93.4014 340.517 88.781 340.517 82.3233V78.6353H340.51Z" fill="#263238"/>
|
||||
<path d="M408.151 44.4957L429.722 116.434H449.255L464.575 64.1936C464.737 63.6353 465.532 63.6211 465.71 64.1794L482.23 116.434H501.759L523.468 44.4957H502.229L491.51 84.5628C491.056 86.3855 490.614 88.1798 490.205 89.9534C489.969 91.0695 489.576 92.8432 489.329 93.9665C489.272 94.2138 488.915 94.2207 488.859 93.9665C488.555 92.6806 488.226 91.3452 487.861 89.9461C487.407 88.1234 486.905 86.3287 486.349 84.5555L474.394 44.4888H454.055C454.055 44.4888 442.051 85.361 439.833 93.5072C439.74 93.8603 439.237 93.8534 439.144 93.5072L426.714 44.4957H408.138H408.151Z" fill="#263238"/>
|
||||
<path d="M529.094 81.5672C529.094 92.2144 532.001 100.926 537.818 107.709C543.676 114.442 551.813 117.812 562.211 117.812C567.616 117.812 572.286 116.837 576.218 114.88C578.959 113.502 582.47 110.414 584.403 108.613C585.194 111.919 586.252 116.455 586.252 116.455H603.279V11.8123H585.356V53.1722C583.07 49.9857 580.045 47.5269 576.291 45.8032C572.537 44.0295 567.976 43.1397 562.621 43.1397C552.547 43.1397 544.439 46.5309 538.301 53.3064C532.167 60.0394 529.094 68.7297 529.094 79.3768V81.5599V81.5672ZM546.616 82.2524L546.551 78.7059C546.551 73.5199 548.152 69.3301 551.359 66.144C554.566 62.9149 559.536 61.2969 566.27 61.2969C570.984 61.2969 574.998 62.3924 578.29 64.5755C578.326 64.5965 578.355 64.6176 578.387 64.6387C582.798 67.5921 585.364 72.5944 585.364 77.8792V82.8248C585.364 88.258 582.656 93.3803 578.055 96.3122C578.018 96.3333 577.982 96.3548 577.949 96.3759C574.422 98.559 570.344 99.654 565.722 99.654C558.94 99.654 554.067 98.036 551.087 94.8074C548.107 91.5787 546.624 87.3888 546.624 82.2455L546.616 82.2524Z" fill="#263238"/>
|
||||
<path d="M623.619 116.448H640.728V44.5026H623.619V116.441V116.448ZM619.983 24.8473C619.983 27.9418 621.037 30.4924 623.141 32.4918C625.298 34.4489 628.314 35.4239 632.21 35.4239C636.106 35.4239 639.037 34.4206 641.141 32.4212C643.294 30.4217 644.368 27.8924 644.368 24.8473C644.368 21.8022 643.318 19.3435 641.206 17.3369C639.098 15.3375 636.078 14.3342 632.137 14.3342C628.196 14.3342 625.298 15.3375 623.141 17.3369C621.037 19.3364 619.983 21.8446 619.983 24.8473Z" fill="#263238"/>
|
||||
<path d="M661.472 116.448H678.58V87.2688C678.58 70.7502 684.463 60.2798 697.261 60.2798C710.064 60.2798 712.792 67.6768 712.792 78.0629V116.448H729.896V73.7883C729.896 61.5515 727.379 53.3769 722.336 49.286C717.296 45.146 710.867 43.076 703.03 43.076C697.861 43.076 693.073 44.0295 688.671 45.9442C684.718 47.64 681.495 50.0563 679.014 53.1791C678.807 52.0699 677.68 44.5099 677.68 44.5099H661.464V116.448H661.472Z" fill="#263238"/>
|
||||
<path d="M107.11 150.245H44.2022C17.4663 150.245 0.166702 133.057 0.166702 106.494V43.5159C0.166702 17.432 17.4663 0.244507 43.7202 0.244507H107.11C133.845 0.244507 151.145 17.432 151.145 43.9946V106.494C151.145 133.057 133.845 150.245 107.11 150.245Z" fill="#263238"/>
|
||||
<path d="M97.1648 102.44C94.3399 102.44 91.8228 101.569 89.7394 99.8627C87.2502 97.8544 85.2714 94.8592 85.2013 91.3391C85.1664 89.5606 87.0471 89.5606 87.0471 89.5606C87.0471 89.5606 90.1026 89.5249 91.5852 89.5249C93.0674 89.5606 93.5008 91.6845 93.5709 92.2172C94.1441 96.9904 96.7663 99.071 98.7799 100.021C99.9897 100.59 99.6889 102.368 97.1648 102.447V102.44Z" fill="white"/>
|
||||
<path d="M71.7973 78.1858C69.3081 77.8833 65.6161 77.6243 63.2319 77.0194C59.3652 76.0403 59.4702 72.4622 59.638 71.2963C60.1067 67.7972 61.3513 64.5576 63.2668 61.5483C65.651 57.8624 69.0775 54.6228 73.4822 51.988C81.7468 47.0563 94.4125 43.9943 107.11 43.9943C109.463 43.9943 126.594 43.9943 126.594 47.5099C126.594 49.4632 122.638 49.2812 120.916 49.2812C107.981 49.2812 98.7726 51.088 91.9204 55.0043C85.166 58.8414 80.4604 64.7162 77.2719 73.4125C76.9363 74.1686 75.8246 78.6463 71.7973 78.193V78.1858Z" fill="white"/>
|
||||
<path d="M83.0818 114.073C76.663 114.21 70.6148 111.222 66.0349 106.01C62.1544 101.597 59.8192 97.1477 59.3855 91.4388C59.1199 87.7095 60.7213 86.4211 62.8955 86.6441C64.3991 86.7953 69.0767 87.0256 71.7544 87.5943C73.7611 88.0119 75.097 89.1568 75.4322 91.511C77.2014 104.066 84.8928 109.005 89.4726 110.07C90.2765 110.257 90.7732 110.79 90.7452 111.748C90.7099 112.662 90.1995 113.914 83.0887 114.066L83.0818 114.073Z" fill="white"/>
|
||||
<path d="M64.7981 125.219C60.2462 125.413 55.792 124.47 54.5194 124.24C49.1636 123.261 44.6745 121.569 40.8289 119.057C31.6205 113.053 26.0618 102.448 25.3975 89.8926C25.2298 86.9125 24.8592 81.2178 31.7603 81.6499C34.6061 81.808 39.123 83.2192 42.3045 84.1622C46.2548 85.2998 48.1639 88.4384 48.1639 91.5414C48.1639 109.244 63.0747 120.558 70.1518 120.558C71.7739 120.558 72.5109 121.08 72.5109 122.12C72.5109 122.902 71.7759 124.924 64.8049 125.226L64.7981 125.219Z" fill="white"/>
|
||||
<path d="M41.0445 73.1964C38.3525 72.6994 35.7234 71.5549 33.1644 70.8998C25.2493 68.8768 26.5988 60.6552 27.3399 58.402C34.5418 36.574 57.4059 29.2741 75.8579 27.3231C93.2332 25.4873 111.412 26.9056 128.347 31.7578C129.557 32.089 132.892 32.9169 132.172 34.9326C131.452 36.9484 129.445 35.5446 110.972 35.127C104.882 34.9903 98.799 35.3862 92.6948 36.3509C82.2207 37.9923 71.4528 41.3976 62.7965 48.5822C58.6225 52.0594 54.9515 56.4941 52.4972 61.7352C51.8542 63.1103 51.3507 64.4851 50.9174 65.8602C50.4767 67.3144 48.8267 74.6145 41.0514 73.2033L41.0445 73.1964Z" fill="white"/>
|
||||
<path d="M85.3408 77.409C86.8229 70.5771 92.1698 60.401 112.615 60.401C114.974 60.401 115.76 61.1822 115.76 61.9634C115.76 62.7446 114.66 63.5263 113.401 63.5263C100.978 63.5263 97.0314 71.1025 94.1648 78.8636C93.2417 81.3617 91.1373 81.7359 88.5013 81.2895C86.6555 80.951 84.6487 80.728 85.3408 77.409Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_450_81">
|
||||
<rect width="729.73" height="150" fill="white" transform="translate(0.166702 0.244507)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.9 KiB |
@@ -1,23 +0,0 @@
|
||||
<svg width="731" height="151" viewBox="0 0 731 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_450_96)">
|
||||
<path d="M189.619 80.681C189.619 91.7425 192.992 100.61 199.738 107.294C206.484 113.974 215.605 117.318 227.106 117.318C238.606 117.318 246.345 114.497 252.098 108.851C257.846 103.162 260.722 96.5725 260.722 89.0773V88.4027H244.084V89.0112C244.084 92.0348 242.769 94.6293 240.144 96.7991C237.518 98.9639 233.128 100.049 226.968 100.049C220.043 100.049 214.971 98.3554 211.754 94.9687C208.537 91.5819 206.934 87.0677 206.934 81.4265V77.7706C206.934 72.1293 208.566 67.6152 211.825 64.2284C215.085 60.8416 220.132 59.1483 226.968 59.1483C232.943 59.1483 237.291 60.1857 240.007 62.2614C242.769 64.3366 244.151 66.9547 244.151 70.1149V70.7943H260.788L260.722 69.9122C260.453 62.3745 257.505 55.8272 251.895 50.2756C246.279 44.6765 238.019 41.8792 227.106 41.8792C215.605 41.8792 206.484 45.2189 199.738 51.9029C192.992 58.5398 189.619 67.4076 189.619 78.5161V80.681Z" fill="white"/>
|
||||
<path d="M274.711 115.284H291.69V80.1146C291.69 72.4119 295.403 68.426 298.478 66.2607C301.558 64.0484 305.068 62.9449 309.004 62.9449C310.225 62.9449 311.336 63.011 312.33 63.1476C313.371 63.2842 314.345 63.4399 315.249 63.6195V42.8976C314.795 42.7184 314.005 42.5157 312.874 42.2891C311.786 42.0629 310.721 41.9493 309.681 41.9493C305.65 41.9493 302.05 43.0342 298.88 45.1995C296.028 47.1523 293.724 49.4493 291.969 52.1003C291.789 52.3739 291.358 52.2608 291.34 51.9304L289.854 43.9119H274.706V115.284H274.711Z" fill="white"/>
|
||||
<path d="M322.184 80.681C322.184 91.7425 325.694 100.61 332.71 107.294C339.726 113.974 348.965 117.318 360.418 117.318C371.871 117.318 381.039 113.978 388.06 107.294C395.123 100.615 398.652 91.7425 398.652 80.681V78.5161C398.652 67.412 395.118 58.5398 388.06 51.9029C381.044 45.2234 371.829 41.8792 360.418 41.8792C349.008 41.8792 339.726 45.2189 332.71 51.9029C325.694 58.5398 322.184 67.4076 322.184 78.5161V80.681ZM339.504 77.7706C339.504 71.5869 341.315 67.0727 344.934 64.2284C348.558 61.3841 353.719 59.9639 360.418 59.9639C367.117 59.9639 372.212 61.3841 375.836 64.2284C379.502 67.0727 381.337 71.5869 381.337 77.7706V81.4265C381.337 87.8368 379.526 92.417 375.907 95.1718C372.33 97.8791 367.169 99.2331 360.423 99.2331C353.677 99.2331 348.492 97.8791 344.873 95.1718C341.296 92.417 339.508 87.8368 339.508 81.4265V77.7706H339.504Z" fill="white"/>
|
||||
<path d="M406.367 43.9071L427.695 115.28H447.009L462.155 63.4545C462.313 62.8979 463.103 62.8886 463.278 63.4448L479.611 115.289H498.913L520.371 43.9168H499.371L488.778 83.6668C488.323 85.4733 487.894 87.2563 487.484 89.0158C487.253 90.1241 486.868 91.8836 486.625 92.9968C486.572 93.2421 486.219 93.247 486.163 92.9968C485.863 91.7235 485.534 90.3933 485.178 89.0109C484.723 87.2044 484.225 85.4214 483.682 83.662L471.864 43.912H451.764C451.764 43.912 439.894 84.459 437.709 92.5395C437.615 92.8886 437.117 92.8837 437.028 92.5347L424.736 43.912H406.375L406.367 43.9071Z" fill="white"/>
|
||||
<path d="M525.917 80.6818C525.917 91.243 528.795 99.8891 534.54 106.616C540.337 113.295 548.372 116.639 558.653 116.639C563.996 116.639 568.614 115.667 572.506 113.729C575.21 112.365 578.684 109.295 580.594 107.507C581.372 110.79 582.422 115.285 582.422 115.285H599.246V11.4788H581.538V52.5118C579.276 49.3512 576.284 46.9127 572.571 45.2003C568.861 43.4408 564.353 42.5591 559.063 42.5591C549.098 42.5591 541.083 45.9223 535.018 52.6484C528.949 59.3279 525.917 67.9504 525.917 78.5165V80.6818ZM543.236 81.356L543.171 77.8371C543.171 72.6908 544.756 68.5354 547.918 65.3798C551.088 62.1771 556.006 60.5733 562.663 60.5733C567.321 60.5733 571.286 61.6581 574.545 63.823C574.578 63.8465 574.61 63.8656 574.642 63.8891C579.001 66.8185 581.538 71.7852 581.538 77.0258V81.9316C581.538 87.3183 578.855 92.4033 574.31 95.3137C574.273 95.3372 574.237 95.3562 574.205 95.3798C570.718 97.5446 566.688 98.6299 562.115 98.6299C555.414 98.6299 550.594 97.0261 547.651 93.823C544.707 90.6203 543.236 86.4645 543.236 81.3657V81.356Z" fill="white"/>
|
||||
<path d="M619.363 115.285H636.272V43.9128H619.363V115.285ZM615.767 24.4126C615.767 27.4834 616.809 30.0117 618.888 31.9975C621.021 33.9409 624.005 34.9079 627.852 34.9079C631.703 34.9079 634.598 33.9126 636.682 31.9268C638.81 29.9409 639.872 27.4362 639.872 24.4126C639.872 21.3891 638.83 18.9503 636.746 16.9645C634.667 14.9787 631.679 13.9834 627.783 13.9834C623.887 13.9834 621.021 14.9787 618.888 16.9645C616.809 18.9503 615.767 21.4315 615.767 24.4126Z" fill="white"/>
|
||||
<path d="M656.786 115.285H673.695V86.3319C673.695 69.9406 679.505 59.5585 692.165 59.5585C704.826 59.5585 707.518 66.8931 707.518 77.2046V115.285H724.424V72.9641C724.424 60.8226 721.934 52.7189 716.956 48.6527C711.974 44.5443 705.613 42.4922 697.869 42.4922C692.757 42.4922 688.026 43.4404 683.676 45.3365C679.764 47.0206 676.582 49.4121 674.133 52.5158C673.926 51.4168 672.811 43.9168 672.811 43.9168H656.786V115.29V115.285Z" fill="white"/>
|
||||
<path d="M105.176 150.245H47.5955C17.7228 150.245 0.896423 133.212 0.896423 103.848V46.6407C0.896423 17.277 17.7228 0.244507 47.5955 0.244507H105.176C135.676 0.244507 151.875 16.6535 151.875 46.6407V103.848C151.875 133.835 135.676 150.245 105.176 150.245Z" fill="white"/>
|
||||
<path d="M97.8937 102.44C95.0688 102.44 92.5517 101.569 90.4679 99.8627C87.9787 97.8544 85.9999 94.8592 85.9302 91.3391C85.8949 89.5606 87.776 89.5606 87.776 89.5606C87.776 89.5606 90.8315 89.5249 92.3137 89.5249C93.7963 89.5606 94.2296 91.6845 94.2994 92.2172C94.873 96.9904 97.4948 99.071 99.5088 100.021C100.718 100.59 100.418 102.368 97.8937 102.447V102.44Z" fill="#263238"/>
|
||||
<path d="M72.5271 78.1857C70.0379 77.8837 66.3459 77.6243 63.9617 77.0198C60.0949 76.0407 60.1999 72.4626 60.3678 71.2963C60.8364 67.7976 62.081 64.558 63.9966 61.5487C66.3808 57.8628 69.8072 54.6232 74.212 51.988C82.4766 47.0567 94.0347 44.3068 106.732 44.3068C116.038 44.3068 125.751 45.5522 125.848 45.5522C126.723 45.6674 127.359 46.5313 127.324 47.5103C127.289 48.4894 126.618 49.2382 125.743 49.3172C124.331 49.2811 122.954 49.2811 121.646 49.2811C108.711 49.2811 99.5024 51.0884 92.6502 55.0047C85.8958 58.8418 81.1902 64.7161 78.0017 73.4129C77.666 74.1686 76.5544 78.6467 72.5271 78.193V78.1857Z" fill="#263238"/>
|
||||
<path d="M83.8115 114.074C77.3927 114.211 71.3445 111.223 66.7646 106.011C62.8841 101.598 60.549 97.1485 60.1152 91.4396C59.8496 87.7107 61.451 86.4219 63.6252 86.6453C65.1288 86.7961 69.8064 87.0268 72.4841 87.5956C74.4909 88.0131 75.8267 89.1576 76.1619 91.5118C77.9311 104.067 85.6225 109.006 90.2023 110.071C91.0063 110.259 91.5029 110.791 91.4749 111.749C91.4396 112.663 90.9292 113.916 83.8184 114.067L83.8115 114.074Z" fill="#263238"/>
|
||||
<path d="M65.5266 125.219C60.9747 125.413 56.5209 124.47 55.2483 124.24C49.8925 123.261 45.4035 121.569 41.5578 119.057C32.3492 113.053 26.7906 102.448 26.1263 89.8926C25.9585 86.9125 25.5879 81.2178 32.4891 81.6499C35.3348 81.808 39.8517 83.2192 43.033 84.1622C46.9837 85.2998 48.8924 88.4384 48.8924 91.5414C48.8924 109.244 64.464 120.943 71.2602 120.943C72.8822 120.943 73.267 121.936 73.0712 122.887C72.9171 123.643 72.5048 124.924 65.5335 125.226L65.5266 125.219Z" fill="#263238"/>
|
||||
<path d="M41.7743 73.196C39.0822 72.6994 36.4532 71.5549 33.8941 70.8998C25.9791 68.8768 27.3286 60.6552 28.0697 58.402C35.2716 36.574 58.1356 29.2741 76.5877 27.3231C93.9629 25.4873 112.143 26.9056 129.077 31.7578C130.287 32.089 133.622 32.9169 132.902 34.9326C132.182 36.9484 130.175 35.5446 111.702 35.127C105.612 34.9902 99.5287 35.3862 93.4245 36.3509C82.9505 37.9923 72.1825 41.3976 63.5263 48.5822C59.3523 52.0594 55.6813 56.4941 53.2274 61.7352C52.584 63.1103 52.0805 64.4851 51.6471 65.8602C51.2064 67.3144 49.5564 74.6145 41.7812 73.2033L41.7743 73.196Z" fill="#263238"/>
|
||||
<path d="M86.0697 77.4094C87.5518 70.5771 94.1384 59.7495 114.527 60.4983C119.205 60.6495 117.065 63.9325 114.758 63.8603C103.228 63.4501 97.7603 71.1029 94.8933 78.8636C93.9706 81.3617 91.8657 81.7359 89.2298 81.2895C87.384 80.951 85.3772 80.728 86.0697 77.4094Z" fill="#263238"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_450_96">
|
||||
<rect width="729.73" height="150" fill="white" transform="translate(0.896423 0.244507)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.0 KiB |