Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2d62504895 | ||
|
|
e34d0cebe5 | ||
|
|
0053d696ff | ||
|
|
6fb0a72a56 | ||
|
|
5b3e91e34d | ||
|
|
d1a5a41e4d | ||
|
|
d0a07686a5 | ||
|
|
ffa4747ed6 | ||
|
|
b963813910 | ||
|
|
58950ed0ef | ||
|
|
1c7a6c952f | ||
|
|
3ae651fece | ||
|
|
bd52983780 | ||
|
|
39daed3502 | ||
|
|
dda47f51ec | ||
|
|
374b9bcc58 | ||
|
|
f0b18019d5 | ||
|
|
5c10f3d866 | ||
|
|
5d839e5420 | ||
|
|
febbdefc0b | ||
|
|
6110440682 | ||
|
|
d0a174d7b7 | ||
|
|
6708570c49 | ||
|
|
bb4bbf4174 | ||
|
|
a3ef6520e7 | ||
|
|
0fec5ce86d | ||
|
|
26e34b6b83 | ||
|
|
007243f2c3 | ||
|
|
9d0a0bba86 | ||
|
|
89a44cc33a | ||
|
|
92856b6f06 | ||
|
|
c968188080 | ||
|
|
db36bc9770 | ||
|
|
35df043f66 | ||
|
|
3c4a26bc06 | ||
|
|
62046c49b5 | ||
|
|
3b73dcf29d | ||
|
|
9b20c46348 | ||
|
|
996ef650db | ||
|
|
1c36ac1d68 | ||
|
|
198c269790 | ||
|
|
58ef309b68 | ||
|
|
54bace451c | ||
|
|
193b15edc1 | ||
|
|
e4ec678512 | ||
|
|
29c18c1e89 | ||
|
|
13d90e8138 | ||
|
|
02ce1ad48c | ||
|
|
8a401de5c9 | ||
|
|
1c5c4d0117 | ||
|
|
d6fee1e3a6 | ||
|
|
d84aeee968 | ||
|
|
c87242142d | ||
|
|
2c84976e28 | ||
|
|
31ed9f41a0 | ||
|
|
700b98fcb7 | ||
|
|
e29f973889 | ||
|
|
6e25780b25 | ||
|
|
3f01a9e58e | ||
|
|
1bed63a4af | ||
|
|
a530fce78c | ||
|
|
87a395683d | ||
|
|
e3f3b8b464 | ||
|
|
b9de35f0d9 | ||
|
|
5827576ffb | ||
|
|
acc9becf1a | ||
|
|
c2837838ee | ||
|
|
304fd93ece | ||
|
|
491bbcadcc | ||
|
|
df63f4b8a7 | ||
|
|
29fc7c419b | ||
|
|
5bc0230a5a | ||
|
|
c9a2c27b2d | ||
|
|
767573ce6d | ||
|
|
713ce91683 | ||
|
|
8478a42cf0 | ||
|
|
5e33f7e8f4 | ||
|
|
2292c25158 | ||
|
|
13a7412498 | ||
|
|
a44846b632 | ||
|
|
454aa20aa9 | ||
|
|
e3b4105424 | ||
|
|
f0ede57786 | ||
|
|
ab10d026d6 | ||
|
|
a2dbd7ce2b | ||
|
|
eca51decc2 | ||
|
|
06455da40a | ||
|
|
700b74a0b7 | ||
|
|
ae64515e93 | ||
|
|
0992726d7e | ||
|
|
38a442c40d | ||
|
|
14bcfb219a | ||
|
|
d0fd1e3ff5 | ||
|
|
3f29ca3add | ||
|
|
bf498b5972 | ||
|
|
2bc42493af | ||
|
|
d18ef2e1a5 | ||
|
|
2409a5786a | ||
|
|
1191bbca67 | ||
|
|
f5804ab458 | ||
|
|
748b509b1d | ||
|
|
696f6f71b5 | ||
|
|
09ebcdf40f | ||
|
|
0ee0b6b2e9 | ||
|
|
fe550ccc36 | ||
|
|
6dcbe78730 | ||
|
|
73b29f78ab | ||
|
|
0124db049b | ||
|
|
a033c3eff6 | ||
|
|
97286739f2 | ||
|
|
a74a8ed044 | ||
|
|
376f72a22f | ||
|
|
77dee57324 | ||
|
|
5d0c92e90d | ||
|
|
868e6de7d9 | ||
|
|
e24f8850d2 | ||
|
|
f30a76229b | ||
|
|
02c6318f60 | ||
|
|
2f87dde48d | ||
|
|
d570b21635 | ||
|
|
b1af5d9339 | ||
|
|
a598a7a7f0 | ||
|
|
f60fc63ee3 | ||
|
|
94eb549d25 | ||
|
|
7a8b5d09c6 | ||
|
|
c15d9f7645 | ||
|
|
a102f62e28 | ||
|
|
c1a58118c2 | ||
|
|
b0d26e3230 | ||
|
|
7e354b74bd | ||
|
|
e20bcb8c14 | ||
|
|
506058aacb | ||
|
|
a7a0e4e652 | ||
|
|
7b394f1437 | ||
|
|
e2e2551db4 | ||
|
|
52e062c0b5 | ||
|
|
e3785030e1 | ||
|
|
e21430a421 | ||
|
|
f66704af88 | ||
|
|
7a65363296 | ||
|
|
8180e8c7b8 | ||
|
|
13b2a5be94 | ||
|
|
4dd5367572 | ||
|
|
e87b05a93a | ||
|
|
68252c35fc | ||
|
|
ac9b280bd5 | ||
|
|
32b8407b1a | ||
|
|
989e8dee5b | ||
|
|
5ed561812f | ||
|
|
cba2eda5d0 | ||
|
|
1c7b44b604 | ||
|
|
5d61e865a8 | ||
|
|
f32a85cec9 | ||
|
|
c99ce90cf8 | ||
|
|
6e2b960bdb | ||
|
|
862c812ee1 | ||
|
|
6424b15b76 | ||
|
|
5e32673358 | ||
|
|
470f187c0b | ||
|
|
8b966946ea | ||
|
|
8579a4c98d | ||
|
|
8deff757a9 | ||
|
|
d227cf64aa | ||
|
|
458af1d840 | ||
|
|
0024aec60a | ||
|
|
ec86536ace | ||
|
|
f7e2bfb078 | ||
|
|
168be7dfb8 | ||
|
|
832d0002e9 | ||
|
|
1f9e3aa9d1 | ||
|
|
dd97c6d71f | ||
|
|
1f5dce2233 | ||
|
|
fe77b14807 | ||
|
|
a36c49fa77 | ||
|
|
589e782d71 | ||
|
|
31f396c01a | ||
|
|
6d20eda423 | ||
|
|
53b5bdc0b5 | ||
|
|
709bf0a526 | ||
|
|
7189c7f203 | ||
|
|
cdef456aac | ||
|
|
8c879841d7 | ||
|
|
937e6b053d | ||
|
|
ac9109c2b6 | ||
|
|
fcc68750cf | ||
|
|
afe52da92d | ||
|
|
a578bd1054 | ||
|
|
116c038861 | ||
|
|
6e6914fe6b | ||
|
|
2bf04f2616 | ||
|
|
6f2e75f22b | ||
|
|
f6c2ae7504 | ||
|
|
890875ad9d | ||
|
|
11953af700 | ||
|
|
3c774102cf | ||
|
|
fbf92160a3 | ||
|
|
a798845865 | ||
|
|
7db57e04c0 | ||
|
|
b23efa773f | ||
|
|
3b41b32f09 | ||
|
|
5513b909e7 | ||
|
|
c1a50d4125 | ||
|
|
7babde2d62 | ||
|
|
08a6415ba8 | ||
|
|
1ee9478200 | ||
|
|
bbe8fb6655 | ||
|
|
6405102cab | ||
|
|
32445a5cd7 | ||
|
|
5494d93e1d | ||
|
|
ad9647a3f4 | ||
|
|
9ee7e3195b | ||
|
|
53dfd4cb09 | ||
|
|
7ceb0f6e39 | ||
|
|
7496461618 | ||
|
|
b47b7824ff | ||
|
|
ec77d13ebd | ||
|
|
c8f7989c1f | ||
|
|
ec612f0902 | ||
|
|
10b2ca8bf2 | ||
|
|
995b1e627b | ||
|
|
f1d4ebb504 | ||
|
|
6c97c880b3 | ||
|
|
8fc3c25714 | ||
|
|
19c4d31710 | ||
|
|
b17919e909 | ||
|
|
4c1c17c693 | ||
|
|
df99470df8 | ||
|
|
a92528cdb7 | ||
|
|
219e6999df | ||
|
|
95ee77f65c | ||
|
|
eac26215a3 | ||
|
|
783af5070d | ||
|
|
359c7f1c80 | ||
|
|
71d3cea100 | ||
|
|
befc5a67fc | ||
|
|
5a2c222d61 | ||
|
|
6a3c75c15c | ||
|
|
b6162d7bb0 | ||
|
|
550e15228e | ||
|
|
518f5b1fb8 | ||
|
|
1e56f940d9 | ||
|
|
e83e9c61b5 | ||
|
|
269f4c8b4d | ||
|
|
24f0af890a | ||
|
|
5ccd98bd0a | ||
|
|
21fe2e195c | ||
|
|
33168aa535 | ||
|
|
2dce78200b | ||
|
|
b92b4c9936 | ||
|
|
c806dc890a | ||
|
|
816023eea6 | ||
|
|
129ac7da38 | ||
|
|
1b80f751a3 | ||
|
|
f30d299949 | ||
|
|
5de1bafdc6 | ||
|
|
2a8abd3a0b | ||
|
|
fdfcd37061 | ||
|
|
5d146ca86e | ||
|
|
e9ec397663 | ||
|
|
c4f552f44a | ||
|
|
1f274d8ae9 | ||
|
|
846050f031 | ||
|
|
d23b35de5e | ||
|
|
2e4c660c97 | ||
|
|
4d5dc3869e | ||
|
|
d4ca61d751 | ||
|
|
5f1da943b8 | ||
|
|
0803ad7e2d | ||
|
|
1326895e6b | ||
|
|
bc17157204 | ||
|
|
880b3b5d37 | ||
|
|
d9d4085591 | ||
|
|
acedc6b116 | ||
|
|
4eac1c0024 | ||
|
|
e86c390862 | ||
|
|
7f877861d1 | ||
|
|
6f97c06e3d | ||
|
|
af4a96822c | ||
|
|
f4bedc668d | ||
|
|
8e8b695cbc | ||
|
|
37a869fa24 | ||
|
|
176cac4fbe | ||
|
|
debfd9167f | ||
|
|
b68b5a7747 | ||
|
|
1aaaaeca20 | ||
|
|
0590367b7f | ||
|
|
209947266b | ||
|
|
8bca7f5390 | ||
|
|
643348046f | ||
|
|
9843f39510 | ||
|
|
14362b92c1 | ||
|
|
ac322a9bd4 | ||
|
|
390f274d06 | ||
|
|
da23b06f71 | ||
|
|
469f1d5cdd | ||
|
|
45a936c05d | ||
|
|
84cafba0c2 | ||
|
|
e1ec60af92 | ||
|
|
3cde03e9cb | ||
|
|
b7c3b84ba2 | ||
|
|
b08b86ca9d | ||
|
|
a37edc2caa | ||
|
|
f0c778b37a | ||
|
|
675a92a17f | ||
|
|
9197729387 | ||
|
|
99cb7f512e | ||
|
|
0f765af468 | ||
|
|
f5ce9af3e0 | ||
|
|
8500c30f59 | ||
|
|
2f4065b5a3 | ||
|
|
fb4ecee897 | ||
|
|
735352c3d3 | ||
|
|
f89ad7cd1a | ||
|
|
bfadfb1a4f | ||
|
|
af306746d3 | ||
|
|
d67513b3f4 | ||
|
|
0bae5f9422 | ||
|
|
791d8e58d9 | ||
|
|
1fca2abd93 | ||
|
|
de2b7eb1ff | ||
|
|
c8840551a1 | ||
|
|
302f112b15 | ||
|
|
f2d8f99fb3 | ||
|
|
7ed2b7dc16 | ||
|
|
acbed2ed74 | ||
|
|
a095cb8255 | ||
|
|
5fb6082cce | ||
|
|
51408eb03c | ||
|
|
c51b69ade5 | ||
|
|
db413378eb | ||
|
|
f82b163c7a | ||
|
|
1a70a847f7 | ||
|
|
b8f3a62bc5 | ||
|
|
4f23f5fe01 | ||
|
|
b5de1b764a | ||
|
|
6444cd3175 | ||
|
|
181aa4070c | ||
|
|
698c46e419 | ||
|
|
bbb878aaf4 | ||
|
|
f54890dc36 | ||
|
|
0737280c72 | ||
|
|
9edfe257c4 | ||
|
|
28c1aaf3ff | ||
|
|
f8c662b064 | ||
|
|
aa8a8e378e | ||
|
|
11edfe5038 | ||
|
|
3ddb2c192d | ||
|
|
5a149f9025 | ||
|
|
9955a64c56 | ||
|
|
1de4b5c6a1 | ||
|
|
b8c5aafa1d | ||
|
|
ac7e354ae2 | ||
|
|
63697f97cb | ||
|
|
45833b9067 | ||
|
|
1562180784 | ||
|
|
7476b1f7f4 | ||
|
|
d0ef127fe1 | ||
|
|
f7849e02ee | ||
|
|
f7e92a516a | ||
|
|
d061829beb | ||
|
|
62f4640cb3 | ||
|
|
fb41d3afc6 | ||
|
|
7124563225 | ||
|
|
a8ede8faed | ||
|
|
b73aeb487b | ||
|
|
11cd2d9b15 | ||
|
|
7aae3fc7d5 | ||
|
|
123e39270c | ||
|
|
fab7c44fa1 | ||
|
|
86430bb143 | ||
|
|
c5b8d3b7b4 | ||
|
|
ceaabe2b66 | ||
|
|
2f6da5266d | ||
|
|
9378dc56e8 | ||
|
|
187b9bcf43 | ||
|
|
a55c1950d0 | ||
|
|
27bcd881a1 | ||
|
|
922ef11883 | ||
|
|
57aac2ab62 | ||
|
|
e26ca2adf1 | ||
|
|
f090573147 | ||
|
|
062748eb5c | ||
|
|
9298a7473e | ||
|
|
bf9cd2b248 | ||
|
|
4f4919566d | ||
|
|
caa8a63d86 | ||
|
|
73322acde1 | ||
|
|
afb8b6389c | ||
|
|
7db0c4d60a | ||
|
|
446c5db68f | ||
|
|
765bf71220 | ||
|
|
d2342b78e6 | ||
|
|
37e50684e3 | ||
|
|
2d31905d56 | ||
|
|
9418bcf44f | ||
|
|
68f8c22770 | ||
|
|
3b7ab09e77 | ||
|
|
6a7032fe90 | ||
|
|
e9d888b07c | ||
|
|
016e9aca77 | ||
|
|
a4843cf7e6 | ||
|
|
12b52af121 | ||
|
|
2aa18aa262 | ||
|
|
28e3fa5271 | ||
|
|
26d5c83814 | ||
|
|
a695f13e3e | ||
|
|
11ce4072d0 | ||
|
|
13dad73c64 | ||
|
|
c4d3dd89d7 | ||
|
|
bdcae326f4 | ||
|
|
563abc074c | ||
|
|
d36fc1b31d | ||
|
|
1bf66e5e4a | ||
|
|
4750410af0 | ||
|
|
0403308847 | ||
|
|
f2ca131aee | ||
|
|
edc46da933 | ||
|
|
c53ecc5476 | ||
|
|
482c0b4982 | ||
|
|
e00efd03a1 | ||
|
|
0984f11f39 | ||
|
|
6e53a0f7a3 | ||
|
|
7131518f3f | ||
|
|
6ba1996084 | ||
|
|
4411ee7d1d | ||
|
|
cd1704740e | ||
|
|
512f59f1e8 | ||
|
|
f6ad346f9b | ||
|
|
a661076686 | ||
|
|
d7abd74af3 | ||
|
|
3f35672f72 | ||
|
|
0555464586 | ||
|
|
94f330e19b | ||
|
|
f716726e80 | ||
|
|
8c6aaf9284 | ||
|
|
aebfd92e09 | ||
|
|
fa7e2dad5f | ||
|
|
64d64958f6 | ||
|
|
c0869fdb3e | ||
|
|
04a9c988e6 | ||
|
|
37c17ce8f3 | ||
|
|
6c7ca9472f | ||
|
|
4dfaafc929 | ||
|
|
afdd3d7736 | ||
|
|
c19b759237 | ||
|
|
12505151c1 | ||
|
|
4687091ebd | ||
|
|
4baecb22e9 | ||
|
|
27758c72e3 | ||
|
|
1580455b3f | ||
|
|
da2f4dba60 | ||
|
|
bc5b4cb9ad | ||
|
|
4c66f5c503 | ||
|
|
cf2c8e3fe4 | ||
|
|
78c435681b | ||
|
|
db83dabe86 | ||
|
|
436efe0f59 | ||
|
|
69b99b9127 | ||
|
|
df5a6e9151 | ||
|
|
bd04dc9b1e | ||
|
|
dfb3ef60dd | ||
|
|
55e94ca792 | ||
|
|
1b0e9f4b0c | ||
|
|
23766ee007 | ||
|
|
7258259cfd | ||
|
|
8c621f0028 | ||
|
|
d109dcca87 | ||
|
|
0846e04ea4 | ||
|
|
d9abcbadac | ||
|
|
2a3ae68948 | ||
|
|
422865b3ad | ||
|
|
32a1a685cc | ||
|
|
cfd161f080 | ||
|
|
0dcf229e19 | ||
|
|
1825fc3283 | ||
|
|
635f743e56 | ||
|
|
16cb8c02ed | ||
|
|
f9d965787d | ||
|
|
55d81f4771 | ||
|
|
545dffa351 | ||
|
|
7c507052fd | ||
|
|
bb28de7571 | ||
|
|
8b217dfcfa | ||
|
|
9c4db2956b | ||
|
|
626c131f7d | ||
|
|
fe9c19fc4e | ||
|
|
33bcadd457 | ||
|
|
9bf125e024 | ||
|
|
78c06abbd4 | ||
|
|
34247f13b6 | ||
|
|
0b4cb71320 | ||
|
|
8171e90a6c | ||
|
|
f7a21df042 | ||
|
|
72852f90e2 | ||
|
|
044ef8bdb5 | ||
|
|
07421a5064 | ||
|
|
1a32f1bc26 | ||
|
|
02fdb8c85b | ||
|
|
8ae9215309 | ||
|
|
277bb2a70b | ||
|
|
457aed5f46 | ||
|
|
36b1930c0d | ||
|
|
f24bbe7631 | ||
|
|
3fb8e06f15 | ||
|
|
f49439f80c | ||
|
|
3fbb473ecb | ||
|
|
f863c7d28d | ||
|
|
d7c5cf51e6 | ||
|
|
a11e1fe339 | ||
|
|
c9d469d973 | ||
|
|
9f36b19e20 | ||
|
|
b6d77bae17 | ||
|
|
ec4cf23c52 | ||
|
|
46c585f325 | ||
|
|
cd63244289 | ||
|
|
204e7d0015 | ||
|
|
6b136608f7 | ||
|
|
f73277b315 | ||
|
|
a703baf054 | ||
|
|
30479c20ee | ||
|
|
cd2d0dcb8e | ||
|
|
60d3eff764 | ||
|
|
a3e6409171 | ||
|
|
aa38217cb6 | ||
|
|
b8f96a837a | ||
|
|
bd33ae7798 | ||
|
|
1bd709f484 | ||
|
|
59378f162f | ||
|
|
cdf36cb49d | ||
|
|
0c069b1e92 | ||
|
|
774720bae0 | ||
|
|
d4f36eecf5 | ||
|
|
0870bfaa02 | ||
|
|
350823e081 | ||
|
|
2db52b7ef2 | ||
|
|
888b4db0bb | ||
|
|
6303071410 | ||
|
|
d1f54010c7 | ||
|
|
49d0882d7c | ||
|
|
5358951557 | ||
|
|
0b6331c28d | ||
|
|
befc4f1cb0 | ||
|
|
b965839091 | ||
|
|
507621e3a8 | ||
|
|
7e4c4f798a | ||
|
|
9b09b47b78 | ||
|
|
3a64eefef7 | ||
|
|
7b820503ae | ||
|
|
0c386671e1 | ||
|
|
56f780e0eb | ||
|
|
3b2698ca19 | ||
|
|
8432fc5f10 | ||
|
|
3996e49ae7 | ||
|
|
a30504e6a5 | ||
|
|
77f704ac9b | ||
|
|
604a89051d | ||
|
|
e0b1d7ed67 | ||
|
|
caa1b56fd3 | ||
|
|
5955f5214d | ||
|
|
2acb34fa90 | ||
|
|
57a28e1462 | ||
|
|
dce63e926e | ||
|
|
90eb4c770f | ||
|
|
820d8720c0 | ||
|
|
3691f726e7 | ||
|
|
7339b7436c | ||
|
|
3364c9fbaa | ||
|
|
db21cbc4ff | ||
|
|
e49c89cc39 | ||
|
|
ba25d92680 | ||
|
|
ccb3513dbd | ||
|
|
e191c3ba67 | ||
|
|
267ae79724 | ||
|
|
08115fa521 | ||
|
|
d4612d6e28 | ||
|
|
0a95ce5a9c | ||
|
|
505f2f473e | ||
|
|
a99198b9b7 | ||
|
|
f2bc6f214d | ||
|
|
323cd07830 | ||
|
|
adc2adc6f4 | ||
|
|
58f2291774 | ||
|
|
1ed89053fe | ||
|
|
b4f72992ef | ||
|
|
f09acfbcaa | ||
|
|
a6652b738f | ||
|
|
041d04cd84 | ||
|
|
faf3ea47f9 | ||
|
|
61f55bfb70 | ||
|
|
73dc01e2df | ||
|
|
800177e76e | ||
|
|
6dbc0a3999 | ||
|
|
148de17b81 | ||
|
|
db3389d6a8 | ||
|
|
7390f38837 | ||
|
|
133e5942cd | ||
|
|
2de0b8792b | ||
|
|
36f1a2bdb3 | ||
|
|
899d0b0d9c | ||
|
|
8c4e21c503 | ||
|
|
a36f202d0a | ||
|
|
fe2f844e60 | ||
|
|
cd80785f29 | ||
|
|
bd065347cb | ||
|
|
e0db70684a | ||
|
|
0298e7836c | ||
|
|
1b9f1db8f8 | ||
|
|
06a7a5192c | ||
|
|
12958cec16 | ||
|
|
e7ca3aefec | ||
|
|
45a626576f | ||
|
|
cf2caccedf | ||
|
|
1d052a7856 | ||
|
|
47dce2c066 | ||
|
|
501f9b366e | ||
|
|
3dfa06308f | ||
|
|
0cb256d930 | ||
|
|
5682f042af | ||
|
|
0a6aa2e672 | ||
|
|
af840e36f9 | ||
|
|
7fc03550e1 | ||
|
|
4a24802637 | ||
|
|
0602ffc646 | ||
|
|
17ead86135 | ||
|
|
8f24cb6769 | ||
|
|
b451f6f8b8 | ||
|
|
bdbb4534ee | ||
|
|
29d732bd68 | ||
|
|
620172709c | ||
|
|
52a4b2831a | ||
|
|
7f0f419a4b | ||
|
|
7eae44c032 | ||
|
|
f50fe242e8 | ||
|
|
de5cf161bf | ||
|
|
8e5182cb26 | ||
|
|
90ce0ad1bc | ||
|
|
2f46acd707 | ||
|
|
a2f0a88e02 | ||
|
|
bdc8353196 | ||
|
|
6c9602e629 | ||
|
|
d18b258761 | ||
|
|
4b1e33db80 | ||
|
|
48727be809 | ||
|
|
d8c605d047 | ||
|
|
d5a0237975 | ||
|
|
f3ad994753 | ||
|
|
1057d390da | ||
|
|
6f83937dbb | ||
|
|
6f6e53bc95 | ||
|
|
ca6724c4da | ||
|
|
0c7e551bf6 | ||
|
|
69d3cd899c | ||
|
|
c52a0d4da7 | ||
|
|
c10e5eb291 | ||
|
|
f41dbb3515 | ||
|
|
a07e4ee0b2 | ||
|
|
6573b7b8c0 | ||
|
|
a72f6d2350 | ||
|
|
210c215beb | ||
|
|
45c8b19081 | ||
|
|
af1739892c | ||
|
|
5221b6f092 | ||
|
|
dec8bbc09e | ||
|
|
6ad4358d70 | ||
|
|
13d91411e3 | ||
|
|
92bb9f96a0 | ||
|
|
9acf7e8d22 | ||
|
|
1aa8aa6900 | ||
|
|
fca61543c5 | ||
|
|
2d35057e57 | ||
|
|
015e284318 | ||
|
|
9a0402d525 | ||
|
|
685dc3aa6e | ||
|
|
9b833076db | ||
|
|
37e94eb7f0 | ||
|
|
d6620e0816 | ||
|
|
a88a794f29 | ||
|
|
4f5ccb9ab8 | ||
|
|
e964965d59 | ||
|
|
fa248c47ad | ||
|
|
d113f84c7e | ||
|
|
eef91cf905 | ||
|
|
2f4fc71ecb | ||
|
|
22933bd412 | ||
|
|
0ba6a444e2 | ||
|
|
0941ba398a | ||
|
|
e7b32890c6 | ||
|
|
d4823c7b5e | ||
|
|
593b1909ab | ||
|
|
20cd4815f6 | ||
|
|
e6b6b99e0f | ||
|
|
a61e38b865 | ||
|
|
e31ef6877b | ||
|
|
67cc7ef258 | ||
|
|
ca6b10bdba | ||
|
|
cf7faa0e28 | ||
|
|
9a607a590a | ||
|
|
c455cbb5ca | ||
|
|
dae142e5ce |
@@ -1,27 +1,15 @@
|
||||
# Android App
|
||||
/app
|
||||
|
||||
# Build Artifacts
|
||||
/schema/dist
|
||||
/server/dist
|
||||
/client/.next
|
||||
|
||||
# IDEs
|
||||
.vscode
|
||||
|
||||
# Project Metadata
|
||||
.crowdin.yml
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
SECURITY.md
|
||||
CHANGELOG.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
# Project Dependencies
|
||||
**/node_modules
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
dist
|
||||
docs
|
||||
.env*
|
||||
/data
|
||||
.nitro
|
||||
.cursor
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
coverage
|
||||
.DS_Store
|
||||
.tanstack
|
||||
node_modules
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
@@ -1,8 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,36 +1,77 @@
|
||||
# Server + Client
|
||||
TZ=UTC
|
||||
PUBLIC_URL=http://localhost:3000
|
||||
PUBLIC_SERVER_URL=http://localhost:3100
|
||||
PUBLIC_GOOGLE_CLIENT_ID=
|
||||
# --- Server ---
|
||||
TZ="Etc/UTC"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Server + Database
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
# Optional, uses APP_URL by default
|
||||
# This can be set to a different URL (like http://host.docker.internal:3000 or http://{docker_service}:3000)
|
||||
# to let the browser navigate to a non-public instance of Reactive Resume
|
||||
PRINTER_APP_URL="http://host.docker.internal:3000"
|
||||
|
||||
# Server
|
||||
SECRET_KEY=
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_SSL_CERT=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRY_TIME=604800
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_API_KEY=
|
||||
MAIL_FROM_NAME=
|
||||
MAIL_FROM_EMAIL=
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
STORAGE_BUCKET=
|
||||
STORAGE_REGION=
|
||||
STORAGE_ENDPOINT=
|
||||
STORAGE_URL_PREFIX=
|
||||
STORAGE_ACCESS_KEY=
|
||||
STORAGE_SECRET_KEY=
|
||||
PDF_DELETION_TIME=345600000
|
||||
# Note: set this to `http://host.docker.internal:3000` if your `gotenberg` service is running on docker, but Reactive Resume is running outside of Docker.
|
||||
|
||||
# Client
|
||||
PUBLIC_FLAG_DISABLE_SIGNUPS=false
|
||||
# --- Printer ---
|
||||
GOTENBERG_ENDPOINT="http://localhost:4000"
|
||||
|
||||
# Gotenberg Authentication (Optional)
|
||||
# TIP: It's safest to avoid exposing your Gotenberg instance to the public internet; connect via a private Docker network instead.
|
||||
# However, if Gotenberg needs to be hosted remotely from Reactive Resume, use these credentials to enable basic authentication for security.
|
||||
# For setup details and security best practices, see: https://gotenberg.dev/docs/configuration#api:~:text=API%5FENABLE%5FBASIC%5FAUTH
|
||||
# GOTENBERG_USERNAME=""
|
||||
# GOTENBERG_PASSWORD=""
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
|
||||
|
||||
# Social Auth (Google, optional)
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Social Auth (GitHub, optional)
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Custom OAuth Provider
|
||||
OAUTH_PROVIDER_NAME=""
|
||||
OAUTH_CLIENT_ID=""
|
||||
OAUTH_CLIENT_SECRET=""
|
||||
OAUTH_DISCOVERY_URL=""
|
||||
OAUTH_AUTHORIZATION_URL=""
|
||||
|
||||
# --- Email (optional) ---
|
||||
# If all keys are disabled, the app logs the email to be sent to the console instead.
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# --- Storage (optional) ---
|
||||
# If all keys are disabled, the app uses local filesystem (/data) to store uploads instead.
|
||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||
|
||||
# Seaweedfs
|
||||
S3_ACCESS_KEY_ID="seaweedfs"
|
||||
S3_SECRET_ACCESS_KEY="seaweedfs"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ENDPOINT="http://localhost:8333"
|
||||
S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- Feature Flags ---
|
||||
# This flag bypasses the check that the endpoint to fetch data for the printing of PDFs `getByIdForPrinter`, is only accessible from the server.
|
||||
# Useful for when you want to debug the /printer/{resumeId} route to quickly take a peek at the page that is sent to the printer.
|
||||
FLAG_DEBUG_PRINTER="false"
|
||||
|
||||
# This flag disables new signups, both on the web app and the server.
|
||||
FLAG_DISABLE_SIGNUP="false"
|
||||
|
||||
# --- Others ---
|
||||
# Google Cloud API Key (optional)
|
||||
# This is not used within Reactive Resume, but in src/scripts/fonts/generate.ts to generate a list of fonts served by Google Fonts.
|
||||
# Note: Make sure "Google Fonts Developer API" is unrestricted.
|
||||
GOOGLE_CLOUD_API_KEY=""
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": ["plugin:@typescript-eslint/recommended"],
|
||||
"plugins": ["@typescript-eslint/eslint-plugin", "unused-imports", "simple-import-sort"],
|
||||
"rules": {
|
||||
// ESLint
|
||||
"no-unused-vars": "off",
|
||||
|
||||
// Unused Imports
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none",
|
||||
"varsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
|
||||
// Simple Import Sort
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
|
||||
// TypeScript ESLint
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.js"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-var-requires": "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": "im.amruth@gmail.com",
|
||||
"description": "Software Engineer",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me/funding.json"
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"guid": "reactive-resume",
|
||||
"name": "Reactive Resume",
|
||||
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/AmruthPillai/Reactive-Resume",
|
||||
"wellKnown": "https://github.com/AmruthPillai/Reactive-Resume/blob/main/.github/.well-known/funding-manifest-urls"
|
||||
},
|
||||
"licenses": ["spdx:MIT"],
|
||||
"tags": ["data", "design", "productivity", "resume-builder"]
|
||||
}
|
||||
],
|
||||
"funding": {
|
||||
"plans": [
|
||||
{
|
||||
"guid": "sponsor",
|
||||
"status": "active",
|
||||
"name": "Sponsor",
|
||||
"description": "Support the project on a recurring basis by becoming a sponsor.",
|
||||
"amount": 10,
|
||||
"currency": "EUR",
|
||||
"frequency": "monthly",
|
||||
"channels": ["github", "open-collective"]
|
||||
},
|
||||
{
|
||||
"guid": "donation",
|
||||
"status": "active",
|
||||
"name": "Donation",
|
||||
"description": "Show your support for the project by making a one-time donation.",
|
||||
"amount": 0,
|
||||
"currency": "EUR",
|
||||
"frequency": "one-time",
|
||||
"channels": ["github", "open-collective"]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"guid": "github",
|
||||
"type": "payment-provider",
|
||||
"description": "GitHub Sponsors",
|
||||
"address": "https://github.com/sponsors/AmruthPillai"
|
||||
},
|
||||
{
|
||||
"guid": "open-collective",
|
||||
"type": "payment-provider",
|
||||
"description": "Open Collective",
|
||||
"address": "https://opencollective.com/reactive-resume"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
github: AmruthPillai
|
||||
custom: https://paypal.me/amruthde
|
||||
open_collective: reactive-resume
|
||||
@@ -1,9 +1,10 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help improve Reactive Resume.
|
||||
|
||||
title: '[Bug] <title>'
|
||||
labels: [Bug, Needs Triage]
|
||||
assignees: 'AmruthPillai'
|
||||
description: Create a bug report to help improve Reactive Resume
|
||||
|
||||
title: "[Bug] <title>"
|
||||
labels: [bug, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
@@ -11,7 +12,7 @@ body:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues
|
||||
- label: Yes, I have searched the existing issues and none of them match my problem.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
@@ -20,47 +21,38 @@ body:
|
||||
label: Product Variant
|
||||
description: What variant of Reactive Resume are you using?
|
||||
options:
|
||||
- Cloud (http://rxresu.me)
|
||||
- Cloud (https://rxresu.me)
|
||||
- Self-Hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- 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
|
||||
id: template
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on?
|
||||
multiple: true
|
||||
label: What template are you using?
|
||||
description: Leave blank if the issue applies to all templates, or is not template-specific.
|
||||
multiple: false
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Azurill
|
||||
- Bronzor
|
||||
- Chikorita
|
||||
- Ditto
|
||||
- Ditgar
|
||||
- Gengar
|
||||
- Glalie
|
||||
- Kakuna
|
||||
- Lapras
|
||||
- Leafish
|
||||
- Onyx
|
||||
- Pikachu
|
||||
- Rhyhorn
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest an feature or idea that you would like to see in Reactive Resume.
|
||||
|
||||
title: '[Feature] <title>'
|
||||
labels: [Feature, Needs Triage]
|
||||
assignees: 'AmruthPillai'
|
||||
description: Suggest an feature or idea that you would like to see in Reactive Resume
|
||||
|
||||
title: "[Feature] <title>"
|
||||
labels: [feature, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
label: Is there an existing issue for this feature?
|
||||
description: Please search to see if an issue already exists for the feature you requested.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues and it doesn't exist
|
||||
- label: Yes, I have searched the existing issues and it doesn't exist.
|
||||
required: true
|
||||
|
||||
- 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
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,22 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/server'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/client'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
|
||||
- package-ecosystem: 'gradle'
|
||||
directory: '/app'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
@@ -1,142 +0,0 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
name: Build (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image:
|
||||
- client
|
||||
- server
|
||||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
|
||||
- name: Retrieve version from package.json
|
||||
id: version
|
||||
uses: martinbeentjes/npm-get-version-action@v1.3.1
|
||||
|
||||
- name: Docker Metadaata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.6.0
|
||||
with:
|
||||
images: amruthpillai/reactive-resume
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.image }}-latest
|
||||
type=raw,value=${{ matrix.image }}-${{ steps.version.outputs.current-version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: $GITHUB_REPOSITORY_OWNER
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
id: build
|
||||
uses: docker/build-push-action@v4.1.1
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
file: ${{ matrix.image }}/Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
build-arm64:
|
||||
name: Build (arm64)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image:
|
||||
- client
|
||||
- server
|
||||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
|
||||
- name: Retrieve version from package.json
|
||||
id: version
|
||||
uses: martinbeentjes/npm-get-version-action@v1.3.1
|
||||
|
||||
- name: Docker Metadaata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.6.0
|
||||
with:
|
||||
images: amruthpillai/reactive-resume
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.image }}-arm64-latest
|
||||
type=raw,value=${{ matrix.image }}-arm64-${{ steps.version.outputs.current-version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: $GITHUB_REPOSITORY_OWNER
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
id: build
|
||||
uses: docker/build-push-action@v4.1.1
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
file: ${{ matrix.image }}/Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: build-amd64
|
||||
|
||||
steps:
|
||||
- name: Install DigitalOcean CLI
|
||||
uses: digitalocean/action-doctl@v2.3.0
|
||||
with:
|
||||
token: ${{ secrets.DIGITALOCEAN_TOKEN }}
|
||||
|
||||
- name: Create Deployment with Latest Version
|
||||
run: doctl apps create-deployment ${{ secrets.DIGITALOCEAN_APP_ID }} --wait --force-rebuild
|
||||
@@ -0,0 +1,180 @@
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
|
||||
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@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,format=short,suffix=-${{ matrix.arch }}
|
||||
type=raw,value=v${{ steps.version.outputs.version }}-${{ matrix.arch }}
|
||||
|
||||
- name: Build and Push by Digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
sbom: true
|
||||
push: true
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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@v6
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: 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@v4
|
||||
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@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,format=short
|
||||
type=raw,value=latest
|
||||
type=raw,value=v${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
|
||||
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:latest
|
||||
|
||||
- name: Redeploy Service
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
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,18 +1,15 @@
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
!.env.gitpod
|
||||
!.env.example
|
||||
|
||||
# Project Dependencies
|
||||
node_modules
|
||||
|
||||
# macOS
|
||||
dist
|
||||
.env*
|
||||
/data
|
||||
.nitro
|
||||
.output
|
||||
.vercel
|
||||
.cursor
|
||||
coverage
|
||||
.netlify
|
||||
.DS_Store
|
||||
|
||||
# Intellij
|
||||
.idea
|
||||
|
||||
# Turborepo
|
||||
.turbo
|
||||
.tanstack
|
||||
node_modules
|
||||
!.env.example
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
@@ -0,0 +1,48 @@
|
||||
stages:
|
||||
- build
|
||||
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
|
||||
build-and-push:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:24-dind
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
before_script:
|
||||
- apk add --no-cache jq
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
|
||||
- |
|
||||
export VERSION=$(jq -r .version package.json)
|
||||
echo "VERSION=$VERSION" >> build.env
|
||||
script:
|
||||
- source build.env
|
||||
- |
|
||||
SHORT_SHA=$CI_COMMIT_SHORT_SHA
|
||||
PRIMARY_TAG="$CI_REGISTRY_IMAGE:$SHORT_SHA"
|
||||
|
||||
# Build the image
|
||||
docker build -f ./Dockerfile -t $PRIMARY_TAG .
|
||||
|
||||
# Tag with additional tags
|
||||
docker tag $PRIMARY_TAG $CI_REGISTRY_IMAGE:latest
|
||||
if [ -n "$VERSION" ] && [ "$VERSION" != "null" ]; then
|
||||
docker tag $PRIMARY_TAG $CI_REGISTRY_IMAGE:v$VERSION
|
||||
fi
|
||||
|
||||
# Push all tags (only if not a merge request)
|
||||
if [ "$CI_PIPELINE_SOURCE" != "merge_request_event" ]; then
|
||||
docker push $PRIMARY_TAG
|
||||
docker push $CI_REGISTRY_IMAGE:latest
|
||||
if [ -n "$VERSION" ] && [ "$VERSION" != "null" ]; then
|
||||
docker push $CI_REGISTRY_IMAGE:v$VERSION
|
||||
fi
|
||||
fi
|
||||
after_script:
|
||||
- docker logout $CI_REGISTRY || true
|
||||
@@ -1,41 +0,0 @@
|
||||
tasks:
|
||||
- name: Run PostgreSQL Database
|
||||
command: docker run --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres
|
||||
|
||||
- name: Install Project Dependencies
|
||||
command: |
|
||||
pnpm install
|
||||
pnpm dlx playwright install --with-deps chromium
|
||||
gp sync-done deps
|
||||
|
||||
- name: Generate Environment Variables
|
||||
init: gp sync-await deps
|
||||
command: |
|
||||
if [ -f .env ]; then
|
||||
echo "Found .env in workspace, skipping generation"
|
||||
else
|
||||
pnpm generate-env
|
||||
fi
|
||||
gp sync-done env
|
||||
|
||||
- name: Build and Run Project
|
||||
init: gp sync-await env
|
||||
command: |
|
||||
pnpm build
|
||||
pnpm start
|
||||
|
||||
ports:
|
||||
# PostgreSQL
|
||||
- port: 5432
|
||||
onOpen: ignore
|
||||
visibility: private
|
||||
|
||||
# Server
|
||||
- port: 3100
|
||||
onOpen: ignore
|
||||
visibility: public
|
||||
|
||||
# Client
|
||||
- port: 3000
|
||||
onOpen: open-browser
|
||||
visibility: public
|
||||
@@ -0,0 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
const nextPackages = ["@monaco-editor/react"];
|
||||
|
||||
const betaPackages = ["drizzle-orm", "drizzle-kit", "@better-auth/core", "@better-auth/passkey", "better-auth"];
|
||||
|
||||
/** @type {import('npm-check-updates').RunOptions} */
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
install: "always",
|
||||
packageManager: "pnpm",
|
||||
target: (packageName) => {
|
||||
if (nextPackages.includes(packageName)) return "@next";
|
||||
if (betaPackages.includes(packageName)) return "@beta";
|
||||
return "latest";
|
||||
},
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
# Android App
|
||||
/app
|
||||
|
||||
# Schema
|
||||
schema/dist
|
||||
|
||||
# Server
|
||||
server/dist
|
||||
|
||||
# Client
|
||||
client/.next
|
||||
client/public/__ENV.js
|
||||
|
||||
# IDEs
|
||||
.vscode
|
||||
|
||||
# Project Metadata
|
||||
LICENSE
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Project Dependencies
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"lokalise.i18n-ally"
|
||||
]
|
||||
}
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "lokalise.i18n-ally"]
|
||||
}
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"editor.wordWrap": "on",
|
||||
"npm.packageManager": "pnpm",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.workingDirectories": [
|
||||
"schema",
|
||||
"client",
|
||||
"server"
|
||||
],
|
||||
"conventionalCommits.scopes": [
|
||||
"client",
|
||||
"server",
|
||||
"docker",
|
||||
"dependencies"
|
||||
]
|
||||
}
|
||||
"[mdx]": {
|
||||
"editor.defaultFormatter": "unifiedjs.vscode-mdx"
|
||||
},
|
||||
"biome.enabled": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"eslint.enable": false,
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"i18n-ally.enabledParsers": ["po"],
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"prettier.enable": false,
|
||||
"search.exclude": {
|
||||
"locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"tailwindCSS.classFunctions": ["cn", "cva"],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["class:\\s*?[\"'`]([^\"'`]*).*?,"],
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -1,128 +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, 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 e-mail 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
|
||||
im.amruth@gmail.com.
|
||||
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.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,48 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- Dependencies Layer ----------
|
||||
FROM node:24-slim AS dependencies
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
RUN mkdir -p /tmp/dev /tmp/prod
|
||||
|
||||
COPY package.json pnpm-lock.yaml /tmp/dev/
|
||||
COPY package.json pnpm-lock.yaml /tmp/prod/
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
cd /tmp/dev && pnpm install --frozen-lockfile
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
cd /tmp/prod && pnpm install --frozen-lockfile --prod
|
||||
|
||||
# ---------- Builder Layer ----------
|
||||
FROM node:24-slim AS builder
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /tmp/dev/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
# ---------- Runtime Layer ----------
|
||||
FROM node:24-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
COPY --from=dependencies /tmp/prod/node_modules ./node_modules
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
ENTRYPOINT ["node", "-r", "reflect-metadata", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,194 @@
|
||||
# Feature Request: AI Chat Builder for Resume Editing
|
||||
|
||||
I don't know much about this, so I'm looking for volunteers to help with the implementation of this feature I'd like to see on Reactive Resume, and I hope the community finds interest in this feature too.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
For this feature to work, the following conditions must be met:
|
||||
|
||||
- User is logged into Reactive Resume
|
||||
- User has set up AI integration on their account
|
||||
- This means that an AI provider, model and API key should be available for usage on the browser through `useAIStore`
|
||||
|
||||
## User Story
|
||||
|
||||
1. I have just created a new resume, or I am currently editing a resume I made earlier and I'm on the builder screen.
|
||||
2. I know what I want, I need to make a few edits here and there, rewrite my summary so it reads better, but I'm lazy.
|
||||
3. I see a button (outline) on the header, right next to the name on the resume and the menu icon that reads **"Build with AI"** and has a sparkles ✨ icon on its left.
|
||||
- This button is only enabled if I have AI integration enabled. If I don't, this button shouldn't be visible at all so as to not hinder the user experience for non-AI enabled users.
|
||||
4. Clicking on the button displays a chat overlay that can be toggled open/close which covers the right portion of the screen (about 380px width).
|
||||
5. The chat window should animate open, sliding from the bottom. Below the window, there should be a floating action button which controls the visibility of this window. If the button hasn't been interacted with in the last 1 minute, hide the button until the user clicks on the "Build with AI" button again.
|
||||
6. The AI chat should display an initial greeting message. Users can type in what they want to change specifically in the resume, some examples include:
|
||||
- "Rewrite the professional summary"
|
||||
- "Translate all of the section headings to German"
|
||||
- "Make my work experience descriptions more impactful"
|
||||
- "Add keywords related to software engineering"
|
||||
7. The request should be sent using `await client.ai.chat({ input: { aiStoreData, resumeId } })`. Ideally, the oRPC chat route should explicitly accept the `resumeId` in its input and have it available in the context, ensuring that any AI-driven changes or updates are accurately mapped to the correct resume.
|
||||
8. This should display the changes to be made to the resume and apply them. Maybe using tools? I'm not too sure if that's what tools are used for.
|
||||
|
||||
## What Already Exists
|
||||
|
||||
The codebase already has the foundational pieces in place:
|
||||
|
||||
### AI Store (`src/integrations/ai/store.ts`)
|
||||
|
||||
Manages the AI configuration stored in localStorage:
|
||||
|
||||
```typescript
|
||||
type AIStoreState = {
|
||||
enabled: boolean;
|
||||
provider: AIProvider; // "openai" | "gemini" | "anthropic" | "ollama" | ...;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
testStatus: TestStatus;
|
||||
};
|
||||
```
|
||||
|
||||
You can check if AI is enabled via:
|
||||
|
||||
```typescript
|
||||
const enabled = useAIStore((state) => state.enabled);
|
||||
```
|
||||
|
||||
### oRPC AI Router (`src/integrations/orpc/router/ai.ts`)
|
||||
|
||||
Existing AI endpoints that demonstrate streaming patterns:
|
||||
|
||||
```typescript
|
||||
// Example: testConnection streams a response
|
||||
testConnection: protectedProcedure
|
||||
.input(z.object({ provider, model, apiKey, baseURL }))
|
||||
.handler(async function* ({ input }) {
|
||||
const stream = streamText({
|
||||
model: getModel(input),
|
||||
messages: [{ role: "user", content: 'Respond with "1"' }],
|
||||
});
|
||||
yield* stream.textStream;
|
||||
}),
|
||||
```
|
||||
|
||||
The router also uses `generateText` with structured output for parsing documents - this pattern could be useful for applying changes to resume data.
|
||||
|
||||
### Resume Store (`src/components/resume/store/resume.ts`)
|
||||
|
||||
Provides `updateResumeData()` for modifying the resume:
|
||||
|
||||
```typescript
|
||||
updateResumeData: (fn) => {
|
||||
set((state) => {
|
||||
if (!state.resume) return state;
|
||||
if (state.resume.isLocked) {
|
||||
// show error toast
|
||||
return state;
|
||||
}
|
||||
fn(state.resume.data as WritableDraft<ResumeData>);
|
||||
syncResume(current(state.resume));
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
### Builder Header (`src/routes/builder/$resumeId/-components/header.tsx`)
|
||||
|
||||
This is where the "Build with AI" button should be placed, next to the resume name and dropdown menu.
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
Here are some implementation hints for anyone interested in picking this up:
|
||||
|
||||
### 1. Button Placement
|
||||
|
||||
Add the button in the `BuilderHeader` component, conditionally rendered based on `useAIStore().enabled`:
|
||||
|
||||
```typescript
|
||||
const enabled = useAIStore((state) => state.enabled);
|
||||
|
||||
// In the JSX, only show if enabled
|
||||
{enabled && (
|
||||
<Button variant="outline" onClick={openChatPanel}>
|
||||
<SparkleIcon />
|
||||
Build with AI
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2. Chat Panel Component
|
||||
|
||||
- Create a new component (e.g., `AIChatPanel`) that renders as an overlay
|
||||
- Width: ~380px, positioned on the right side of the screen
|
||||
- Animate in from the bottom using Framer Motion (already in the project as `motion/react`)
|
||||
- Include a floating action button (FAB) below the panel for quick toggle
|
||||
- Auto-hide the FAB after 1 minute of inactivity
|
||||
|
||||
### 3. Chat State Management
|
||||
|
||||
Consider creating a new Zustand store for chat state:
|
||||
|
||||
```typescript
|
||||
type AIChatStore = {
|
||||
isOpen: boolean;
|
||||
messages: ChatMessage[];
|
||||
lastInteraction: number;
|
||||
// actions...
|
||||
};
|
||||
```
|
||||
|
||||
### 4. oRPC Route for Chat
|
||||
|
||||
Create a new route in `src/integrations/orpc/router/ai.ts`:
|
||||
|
||||
```typescript
|
||||
chat: protectedProcedure
|
||||
.input(z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
resumeId: z.string(),
|
||||
message: z.string(),
|
||||
}))
|
||||
.handler(async function* ({ input, context }) {
|
||||
// Get resume data for context
|
||||
// Stream AI response
|
||||
// Optionally use tools for structured changes
|
||||
}),
|
||||
```
|
||||
|
||||
### 5. Applying Changes
|
||||
|
||||
The tricky part is how to apply AI-suggested changes to the resume. Some options:
|
||||
|
||||
- **AI SDK Tools**: Use the Vercel AI SDK's tool calling feature to let the AI invoke specific functions like `updateSummary`, `translateHeadings`, etc.
|
||||
- **Structured Output**: Have the AI return a JSON patch or specific update instructions that can be applied via `updateResumeData()`
|
||||
- **Diff Display**: Show proposed changes before applying them, letting users accept/reject
|
||||
|
||||
### 6. Resume Data Schema
|
||||
|
||||
The resume data schema is defined in `src/schema/resume/data.ts` and includes sections like:
|
||||
- `basics` (name, headline, summary, contact info)
|
||||
- `experience`, `education`, `skills`, `projects`, etc.
|
||||
- `metadata` (template, typography, colors)
|
||||
- `customSections`
|
||||
|
||||
## Design Principles
|
||||
|
||||
The premise is simple and nothing that hasn't been done before, but I'd like to see it done **subtly** as this is not an AI-first app, it should just be AI-enabled.
|
||||
|
||||
- The feature should feel like an optional enhancement, not a core requirement
|
||||
- Non-AI users should not feel like they're missing out or see disabled features
|
||||
- The UI should be clean and non-intrusive
|
||||
- Performance should not be impacted when AI features are not in use
|
||||
|
||||
## Getting Started
|
||||
|
||||
If you're interested in contributing:
|
||||
|
||||
1. Fork the repository
|
||||
2. Set up the development environment (see README)
|
||||
3. Configure AI settings in Dashboard > Settings > AI
|
||||
4. Start exploring the code references mentioned above
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to ask questions in the comments below. I'm happy to provide more context or clarify any requirements.
|
||||
|
||||
---
|
||||
|
||||
**Labels**: `enhancement`, `help wanted`, `good first issue`, `ai`
|
||||
@@ -1,179 +1,244 @@
|
||||
<img src="/client/public/logo/dark.svg" alt="Reactive Resume" width="256px" height="256px" />
|
||||
<!--
|
||||
<div align="center" markdown="1">
|
||||
<a href="https://go.warp.dev/Reactive-Resume">
|
||||
<img alt="Warp Sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png?raw=true" />
|
||||
</a>
|
||||
|
||||
# Reactive Resume
|
||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/Reactive-Resume)
|
||||
|
||||
[](https://github.com/AmruthPillai/Reactive-Resume/releases)
|
||||
[](https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE)
|
||||
[](https://translate.rxresu.me)
|
||||
[](https://hub.docker.com/r/amruthpillai/reactive-resume)
|
||||

|
||||
[](https://gitpod.io/#https://github.com/AmruthPillai/Reactive-Resume)
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2FAmruthPillai%2FReactive-Resume?ref=badge_shield)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/Reactive-Resume)
|
||||
</div>
|
||||
|
||||
## [Go to App](https://rxresu.me) | [Docs](https://docs.rxresu.me)
|
||||
---
|
||||
-->
|
||||
|
||||
Reactive Resume is a free and open source resume builder that’s built to make the mundane tasks of creating, updating and sharing your resume as easy as 1, 2, 3. With this app, you can create multiple resumes, share them with recruiters through a unique link and print as PDF, all for free, no advertisements, without losing the integrity and privacy of your data.
|
||||
<div align="center">
|
||||
<a href="https://rxresu.me">
|
||||
<img src="public/opengraph/banner.jpg" alt="Reactive Resume" />
|
||||
</a>
|
||||
|
||||
You have complete control over what goes into your resume, how it looks, what colors, what templates, even the layout in which sections placed. Want a dark mode resume? It’s as easy as editing 3 values and you’re done. You don’t need to wait to see your changes either. Everything you type, everything you change, appears immediately on your resume and gets updated in real time.
|
||||
<h1>Reactive Resume</h1>
|
||||
|
||||
## ❗️ Important Notice
|
||||
<p>Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.</p>
|
||||
|
||||
Due to increasing and recurring costs from Google Cloud, I would have to take down the Version 1 and Version 2 editions of the application that live on https://v1.rxresu.me/ and https://v2.rxresu.me/ respectively.
|
||||
<p>
|
||||
<a href="https://rxresu.me"><strong>Get Started</strong></a>
|
||||
·
|
||||
<a href="https://docs.rxresu.me"><strong>Learn More</strong></a>
|
||||
</p>
|
||||
|
||||
I plan to take down the servers on 1st September 2023, so if you have any data on the earlier versions, please migrate it over to the latest version on https://rxresu.me/ as soon as you can.
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/stars/AmruthPillai/Reactive-Resume?style=flat-square" alt="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/hzwkZbyvUW"><img src="https://img.shields.io/discord/1173518977851473940?label=Discord&style=flat-square&logo=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://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/all/reactive-resume?style=flat-square" alt="Open Collective" /></a>
|
||||
</p>
|
||||
|
||||
The current version will remain unchanged, and is still actively supported. It will have no effect on these changes.
|
||||
<a href="https://www.producthunt.com/products/reactive-resume?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-reactive-resume-v5-2" target="_blank" rel="noopener noreferrer"><img alt="Reactive Resume v5 - A free and open-source resume builder. | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065182&theme=light&t=1768850745585"></a>
|
||||
</div>
|
||||
|
||||
Thank you to the 400,000+ people using the app! 🙏
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
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.
|
||||
|
||||
- [Reactive Resume](#reactive-resume)
|
||||
- [Go to App | Docs](#go-to-app--docs)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Languages](#languages)
|
||||
- [Tutorial](#tutorial)
|
||||
- [Build from Source](#build-from-source)
|
||||
- [Contributing](#contributing)
|
||||
- [Report Bugs and Feature Requests](#report-bugs-and-feature-requests)
|
||||
- [Donations](#donations)
|
||||
- [GitHub Sponsor](#github-sponsor)
|
||||
- [PayPal](#paypal)
|
||||
- [GitHub Star History](#github-star-history)
|
||||
- [Infrastructure](#infrastructure)
|
||||
- [Contributors Wall](#contributors-wall)
|
||||
- [License](#license)
|
||||
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
|
||||
- No Advertising
|
||||
- No User Tracking
|
||||
- Sync your data across devices
|
||||
- Accessible in multiple languages
|
||||
- Import data from [LinkedIn](https://www.linkedin.com/), [JSON Resume](https://jsonresume.org/)
|
||||
- Manage multiple resumes with one account
|
||||
- Open Source (with large community support)
|
||||
- Send your resume to others with a unique sharable link
|
||||
- Pick any font from [Google Fonts](https://fonts.google.com/) to use on your resume
|
||||
- Choose from 6 vibrant templates and more coming soon
|
||||
- Export your resume to JSON or PDF format with just one click
|
||||
- Create an account using your email, or just Sign in with Google
|
||||
- Mix and match colors to any degree, even a dark mode resume?
|
||||
- Add sections, add pages and change layouts the way you want to
|
||||
- Tailor-made Backend and Database, isolated from Google, Amazon etc.
|
||||
- **Oh, and did I mention that it's free?**
|
||||
**Resume Building**
|
||||
|
||||
## Languages
|
||||
- Real-time preview as you type
|
||||
- Multiple export formats (PDF, JSON)
|
||||
- Drag-and-drop section ordering
|
||||
- Custom sections for any content type
|
||||
- Rich text editor with formatting support
|
||||
|
||||
- Amharic (አማርኛ)
|
||||
- Arabic (اَلْعَرَبِيَّةُ)
|
||||
- Bengali (বাংলা)
|
||||
- Bulgarian (български)
|
||||
- Catalan (Valencian)
|
||||
- Chinese (中文)
|
||||
- Czech (čeština)
|
||||
- Danish (Dansk)
|
||||
- Dutch (Nederlands)
|
||||
- English
|
||||
- Finnish (Suomi)
|
||||
- French (Français)
|
||||
- German (Deutsch)
|
||||
- Greek (Ελληνικά)
|
||||
- Hebrew (Ivrit)
|
||||
- Hindi (हिन्दी)
|
||||
- Hungarian (Magyar)
|
||||
- Indonesian (Bahasa Indonesia)
|
||||
- Italian (Italiano)
|
||||
- Japanese (日本語)
|
||||
- Kannada (ಕನ್ನಡ)
|
||||
- Khmer (ភាសាខ្មែរ)
|
||||
- Korean (한국어)
|
||||
- Malayalam (മലയാളം)
|
||||
- Marathi (मराठी)
|
||||
- Nepali (नेपाली)
|
||||
- Norwegian (Norsk)
|
||||
- Odia (ଓଡ଼ିଆ)
|
||||
- Persian (فارسی)
|
||||
- Polish (Polski)
|
||||
- Portuguese (Português)
|
||||
- Romanian (limba română)
|
||||
- Russian (русский)
|
||||
- Serbian (српски језик)
|
||||
- Spanish (Español)
|
||||
- Swedish (Svenska)
|
||||
- Tamil (தமிழ்)
|
||||
- Turkish (Türkçe)
|
||||
- Ukrainian (Українська мова)
|
||||
- Vietnamese (Tiếng Việt)
|
||||
**Templates**
|
||||
|
||||
Help by [translating Reactive Resume](https://translate.rxresu.me) to your language!
|
||||
- Professionally designed templates
|
||||
- A4 and Letter size support
|
||||
- Customizable colors, fonts, and spacing
|
||||
- Custom CSS for advanced styling
|
||||
|
||||
## Tutorial
|
||||
**Privacy & Control**
|
||||
|
||||
The docs include an extensive [Tutorial](https://docs.rxresu.me/tutorial) section which outline the features of Reactive Resume and help you through building your first resume on the app.
|
||||
- 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
|
||||
|
||||
## Build from Source
|
||||
**Extras**
|
||||
|
||||
[](https://gitpod.io/#https://github.com/AmruthPillai/Reactive-Resume)
|
||||
- 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
|
||||
|
||||
Initially building the image and project on Gitpod will take at least ~10 minutes, so please be patient on first launch.
|
||||
## Templates
|
||||
|
||||
For extensive information on how to build the app on your local machine, head over to the docs [Source Code](https://docs.rxresu.me/source-code) section.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/azurill.jpg" alt="Azurill" width="150" />
|
||||
<br /><sub><b>Azurill</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
|
||||
<br /><sub><b>Bronzor</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/chikorita.jpg" alt="Chikorita" width="150" />
|
||||
<br /><sub><b>Chikorita</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/ditto.jpg" alt="Ditto" width="150" />
|
||||
<br /><sub><b>Ditto</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
|
||||
<br /><sub><b>Gengar</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/glalie.jpg" alt="Glalie" width="150" />
|
||||
<br /><sub><b>Glalie</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
|
||||
<br /><sub><b>Kakuna</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/lapras.jpg" alt="Lapras" width="150" />
|
||||
<br /><sub><b>Lapras</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
|
||||
<br /><sub><b>Leafish</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/onyx.jpg" alt="Onyx" width="150" />
|
||||
<br /><sub><b>Onyx</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
|
||||
<br /><sub><b>Pikachu</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/rhyhorn.jpg" alt="Rhyhorn" width="150" />
|
||||
<br /><sub><b>Rhyhorn</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
|
||||
<br /><sub><b>Ditgar</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
|
||||
The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AmruthPillai/Reactive-Resume.git
|
||||
cd Reactive-Resume
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# Access the app
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
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 | Radix UI |
|
||||
| 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/guides/self-hosting-with-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
|
||||
- **Gotenberg** — Headless Chrome service for PDF generation
|
||||
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
||||
|
||||
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/guides/self-hosting-with-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">
|
||||
<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
|
||||
|
||||
## Contributing
|
||||
|
||||
This project makes use of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) style and workflow for commit messages to ensure that the CHANGELOG is auto-generated. In general, this project follows the "fork-and-pull" Git workflow.
|
||||
Contributions make open-source thrive. Whether fixing a typo or adding a feature, all contributions are welcome.
|
||||
|
||||
1. **Fork** the repo on GitHub
|
||||
2. **Clone** the project to your own machine
|
||||
3. **Commit** changes to your own branch
|
||||
4. **Push** your work back up to your fork
|
||||
5. Submit a **Pull Request** so that we can review your changes
|
||||
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
|
||||
|
||||
NOTE: Be sure to merge the latest from `main` before making a pull request!
|
||||
|
||||
## Report Bugs and Feature Requests
|
||||
|
||||
Use the [GitHub Issues](https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose) platform to notify me about bugs or new features that you would like to see in Reactive Resume. Please check before creating new issues as there might already be one.
|
||||
|
||||
## Donations
|
||||
|
||||
Reactive Resume would be nothing without the folks who supported me and kept the project alive in the beginning, and your continued support is what keeps me going. If you found Reactive Resume to be useful, helpful or just insightful and appreciate the effort I took to make the project, please consider donating as little or as much as you can.
|
||||
|
||||
### [GitHub Sponsor](https://github.com/sponsors/AmruthPillai)
|
||||
### [PayPal](https://paypal.me/RajaRajanA)
|
||||
|
||||
## GitHub Star History
|
||||
|
||||
[](https://star-history.com/#AmruthPillai/Reactive-Resume&Date)
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- [Next.js](https://nextjs.org/), frontend
|
||||
- [NestJS](https://nestjs.com/), backend
|
||||
- [PostgreSQL](https://www.postgresql.org/), database
|
||||
- [DigitalOcean](https://www.digitalocean.com/), infrastructure provider
|
||||
- [Crowdin](https://translate.rxresu.me/), translation management platform
|
||||
|
||||
|
||||
|
||||
<a href="https://pillai.xyz/digitalocean">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="200px" />
|
||||
</a>
|
||||
|
||||
## Contributors Wall
|
||||
<a href="https://github.com/AmruthPillai/Reactive-Resume/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AmruthPillai/Reactive-Resume" />
|
||||
</a>
|
||||
|
||||
|
||||
_Note: It may take up to 24h for the [contrib.rocks](https://contrib.rocks/image?repo=AmruthPillai/Reactive-Resume) plugin to update because it's refreshed once a day._
|
||||
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](https://choosealicense.com/licenses/mit/) 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://amruthpillai.com/)
|
||||
[MIT](./LICENSE) — do whatever you want with it.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.x.x | :white_check_mark: |
|
||||
| 2.x.x | :x: |
|
||||
| 1.x.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Create an issue on GitHub or send me an email through the contact form on my website at https://amruthpillai.com/
|
||||
@@ -1,33 +0,0 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
@@ -1,8 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,2 +0,0 @@
|
||||
/build
|
||||
/release
|
||||
@@ -1,47 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "me.rxresu.app"
|
||||
minSdk 21
|
||||
targetSdk 32
|
||||
versionCode 3
|
||||
versionName "1.0"
|
||||
resConfigs 'en'
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
zipAlignEnabled true
|
||||
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
namespace 'me.rxresu.app'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.5.0'
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,21 +0,0 @@
|
||||
package me.rxresu.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
internal class CustomWebViewClient : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
val hostname = "rxresu.me"
|
||||
val uri = Uri.parse(url)
|
||||
|
||||
if (uri.host != null && uri.host!!.endsWith(hostname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
view.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package me.rxresu.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
private var url = "https://rxresu.me"
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
webView = findViewById(R.id.webview)
|
||||
|
||||
webView.webViewClient = CustomWebViewClient()
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.settings.userAgentString = "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Mobile Safari/537.36"
|
||||
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<include
|
||||
layout="@layout/content_main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="visible" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Reactive Resume</string>
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '7.4.2' apply false
|
||||
id 'com.android.library' version '7.4.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -1,6 +0,0 @@
|
||||
#Wed Mar 09 21:34:49 CET 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -1,89 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1,16 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "Reactive Resume"
|
||||
include ':app'
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!**/webfontlist.json", "!**/docs/spec.json", "!**/src/routeTree.gen.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"recommended": true,
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedProperties": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": "off",
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownTypeSelector": "off",
|
||||
"noUnusedImports": {
|
||||
"fix": "safe",
|
||||
"level": "error",
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
"nursery": {
|
||||
"useSortedClasses": {
|
||||
"fix": "safe",
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"functions": ["cn", "cva"],
|
||||
"attributes": ["class", "className"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"extends": ["../.eslintrc.json", "next/core-web-vitals", "plugin:tailwindcss/recommended"],
|
||||
"plugins": ["unused-imports"],
|
||||
"ignorePatterns": [".next", "__ENV.js"],
|
||||
"settings": {
|
||||
"next": {
|
||||
"rootDir": "client"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
// Next.js
|
||||
"@next/next/no-img-element": "off",
|
||||
"@next/next/no-sync-scripts": "off",
|
||||
|
||||
// React
|
||||
"react/no-unescaped-entities": "off",
|
||||
|
||||
// React Hooks
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
|
||||
// Unused Imports
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none",
|
||||
"varsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
|
||||
// Tailwind CSS
|
||||
"tailwindcss/no-custom-classname": ["warn", { "whitelist": ["preview-mode", "printer-mode", "markdown"] }]
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# react-env
|
||||
__ENV.js
|
||||
|
||||
# next-sitemap
|
||||
sitemap*.xml
|
||||
@@ -1,45 +0,0 @@
|
||||
FROM node:lts-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache g++ git make curl python3 libc6-compat \
|
||||
&& corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
FROM base as dependencies
|
||||
|
||||
COPY package.json pnpm-*.yaml ./
|
||||
COPY ./schema/package.json ./schema/package.json
|
||||
COPY ./client/package.json ./client/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base as builder
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=dependencies /app/schema/node_modules ./schema/node_modules
|
||||
COPY --from=dependencies /app/client/node_modules ./client/node_modules
|
||||
|
||||
ARG TURBO_TOKEN
|
||||
ENV TURBO_TOKEN=$TURBO_TOKEN
|
||||
|
||||
RUN pnpm exec turbo --filter client build
|
||||
|
||||
FROM base as production
|
||||
|
||||
COPY --from=builder /app/package.json /app/pnpm-*.yaml ./
|
||||
COPY --from=builder /app/client/package.json ./client/package.json
|
||||
|
||||
RUN pnpm install --filter client --prod --frozen-lockfile --workspace-root
|
||||
|
||||
COPY --from=builder /app/client/.next ./client/.next
|
||||
COPY --from=builder /app/client/public ./client/public
|
||||
COPY --from=builder /app/client/next.config.js ./client/next.config.js
|
||||
COPY --from=builder /app/client/next-i18next.config.js ./client/next-i18next.config.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
CMD [ "pnpm", "run", "--filter", "client", "start" ]
|
||||
@@ -1,43 +0,0 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn global add pnpm && pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
CMD ["node", "client/server.js"]
|
||||
@@ -1,28 +0,0 @@
|
||||
.container {
|
||||
@apply flex w-auto items-center justify-center;
|
||||
@apply fixed inset-x-0 bottom-6;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
}
|
||||
|
||||
.pushLeft {
|
||||
@apply xl:ml-[30vw] 2xl:ml-[28vw];
|
||||
}
|
||||
|
||||
.pushRight {
|
||||
@apply xl:mr-[30vw] 2xl:mr-[28vw];
|
||||
}
|
||||
|
||||
.controller {
|
||||
@apply z-20 flex items-center justify-center shadow-lg;
|
||||
@apply flex rounded-l-full rounded-r-full px-4;
|
||||
@apply bg-zinc-50 dark:bg-zinc-900;
|
||||
@apply opacity-70 transition-opacity duration-200 hover:opacity-100;
|
||||
|
||||
> button {
|
||||
@apply px-2.5 py-2.5;
|
||||
}
|
||||
|
||||
> hr {
|
||||
@apply mx-3 h-5 w-0.5 bg-zinc-900/40 dark:bg-zinc-50/20;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import {
|
||||
AlignHorizontalCenter,
|
||||
AlignVerticalCenter,
|
||||
Download,
|
||||
FilterCenterFocus,
|
||||
InsertPageBreak,
|
||||
Link,
|
||||
RedoOutlined,
|
||||
UndoOutlined,
|
||||
ViewSidebar,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@mui/icons-material';
|
||||
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
import { ReactZoomPanPinchHandlers } from 'react-zoom-pan-pinch';
|
||||
import { ActionCreators } from 'redux-undo';
|
||||
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
|
||||
import { togglePageBreakLine, togglePageOrientation, toggleSidebar } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
import { cn } from '@/utils/styles';
|
||||
|
||||
import styles from './ArtboardController.module.scss';
|
||||
|
||||
const ArtboardController: React.FC<ReactZoomPanPinchHandlers> = ({ zoomIn, zoomOut, centerView }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
|
||||
const { past, present: resume, future } = useAppSelector((state) => state.resume);
|
||||
const pages = get(resume, 'metadata.layout');
|
||||
const { left, right } = useAppSelector((state) => state.build.sidebar);
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
|
||||
|
||||
const handleUndo = () => dispatch(ActionCreators.undo());
|
||||
const handleRedo = () => dispatch(ActionCreators.redo());
|
||||
|
||||
const handleTogglePageBreakLine = () => dispatch(togglePageBreakLine());
|
||||
|
||||
const handleTogglePageOrientation = () => dispatch(togglePageOrientation());
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebar({ sidebar: 'left' }));
|
||||
dispatch(toggleSidebar({ sidebar: 'right' }));
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const url = getResumeUrl(resume, { withHost: true });
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const slug = get(resume, 'slug');
|
||||
const username = get(resume, 'user.username');
|
||||
const updatedAt = get(resume, 'updatedAt');
|
||||
|
||||
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
|
||||
|
||||
download(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
[styles.container]: true,
|
||||
[styles.pushLeft]: left.open,
|
||||
[styles.pushRight]: right.open,
|
||||
})}
|
||||
>
|
||||
<div className={styles.controller}>
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.undo')}>
|
||||
<ButtonBase onClick={handleUndo} className={cn({ 'pointer-events-none opacity-50': past.length < 2 })}>
|
||||
<UndoOutlined fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.redo')}>
|
||||
<ButtonBase onClick={handleRedo} className={cn({ 'pointer-events-none opacity-50': future.length === 0 })}>
|
||||
<RedoOutlined fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-in')}>
|
||||
<ButtonBase onClick={() => zoomIn(0.25)}>
|
||||
<ZoomIn fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-out')}>
|
||||
<ButtonBase onClick={() => zoomOut(0.25)}>
|
||||
<ZoomOut fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.center-artboard')}>
|
||||
<ButtonBase onClick={() => centerView(0.95)}>
|
||||
<FilterCenterFocus fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isDesktop && (
|
||||
<>
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-orientation')}>
|
||||
<ButtonBase
|
||||
onClick={handleTogglePageOrientation}
|
||||
className={cn({ 'pointer-events-none opacity-50': pages.length === 1 })}
|
||||
>
|
||||
{orientation === 'vertical' ? (
|
||||
<AlignHorizontalCenter fontSize="medium" />
|
||||
) : (
|
||||
<AlignVerticalCenter fontSize="medium" />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-page-break-line')}>
|
||||
<ButtonBase onClick={handleTogglePageBreakLine}>
|
||||
<InsertPageBreak fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-sidebars')}>
|
||||
<ButtonBase onClick={handleToggleSidebar}>
|
||||
<ViewSidebar fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.copy-link')}>
|
||||
<ButtonBase onClick={handleCopyLink}>
|
||||
<Link fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.export-pdf')}>
|
||||
<ButtonBase onClick={handleExportPDF} disabled={isLoading}>
|
||||
<Download fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtboardController;
|
||||
@@ -1,13 +0,0 @@
|
||||
.center {
|
||||
@apply mx-0 flex flex-grow pt-12 lg:pt-16;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
@apply bg-zinc-100 dark:bg-zinc-900;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@apply h-full w-full overflow-visible #{!important};
|
||||
}
|
||||
|
||||
.artboard {
|
||||
@apply flex gap-8;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { cn } from '@/utils/styles';
|
||||
|
||||
import ArtboardController from './ArtboardController';
|
||||
import styles from './Center.module.scss';
|
||||
import Header from './Header';
|
||||
import Page from './Page';
|
||||
|
||||
const Center = () => {
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
const layout: string[][][] = get(resume, 'metadata.layout');
|
||||
|
||||
if (isEmpty(resume)) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(styles.center)}>
|
||||
<Header />
|
||||
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
minScale={0.25}
|
||||
initialScale={0.95}
|
||||
limitToBounds={false}
|
||||
centerZoomedOut={false}
|
||||
pinch={{ step: 1 }}
|
||||
wheel={{ step: 0.1 }}
|
||||
>
|
||||
{(controllerProps) => (
|
||||
<>
|
||||
<TransformComponent wrapperClass={styles.wrapper}>
|
||||
<div
|
||||
className={cn({
|
||||
[styles.artboard]: true,
|
||||
'flex-col': orientation === 'vertical',
|
||||
})}
|
||||
>
|
||||
{layout.map((_, pageIndex) => (
|
||||
<Page key={pageIndex} page={pageIndex} showPageNumbers />
|
||||
))}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
|
||||
<ArtboardController {...controllerProps} />
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Center;
|
||||
@@ -1,25 +0,0 @@
|
||||
.header {
|
||||
@apply mx-0 flex justify-between shadow;
|
||||
@apply bg-zinc-900 text-zinc-100;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
|
||||
button > svg {
|
||||
@apply text-base text-zinc-100;
|
||||
}
|
||||
}
|
||||
|
||||
.pushLeft {
|
||||
@apply xl:ml-[30vw] 2xl:ml-[28vw];
|
||||
}
|
||||
|
||||
.pushRight {
|
||||
@apply xl:mr-[30vw] 2xl:mr-[28vw];
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply flex items-center justify-center;
|
||||
|
||||
h1 {
|
||||
@apply ml-2;
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
CopyAll,
|
||||
Delete,
|
||||
DriveFileRenameOutline,
|
||||
Home as HomeIcon,
|
||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||
Link as LinkIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Resume } from 'schema';
|
||||
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { deleteResume, DeleteResumeParams, duplicateResume, DuplicateResumeParams } from '@/services/resume';
|
||||
import { setSidebarState, toggleSidebar } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
import { cn } from '@/utils/styles';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
const Header = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
const { left, right } = useAppSelector((state) => state.build.sidebar);
|
||||
|
||||
const { mutateAsync: deleteMutation } = useMutation<void, ServerError, DeleteResumeParams>(deleteResume);
|
||||
const { mutateAsync: duplicateMutation } = useMutation<Resume, ServerError, DuplicateResumeParams>(duplicateResume);
|
||||
|
||||
const name = useMemo(() => get(resume, 'name'), [resume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) {
|
||||
dispatch(setSidebarState({ sidebar: 'left', state: { open: true } }));
|
||||
dispatch(setSidebarState({ sidebar: 'right', state: { open: true } }));
|
||||
} else {
|
||||
dispatch(setSidebarState({ sidebar: 'left', state: { open: false } }));
|
||||
dispatch(setSidebarState({ sidebar: 'right', state: { open: false } }));
|
||||
}
|
||||
}, [isDesktop, dispatch]);
|
||||
|
||||
const toggleLeftSidebar = () => dispatch(toggleSidebar({ sidebar: 'left' }));
|
||||
|
||||
const toggleRightSidebar = () => dispatch(toggleSidebar({ sidebar: 'right' }));
|
||||
|
||||
const goBack = () => router.push('/dashboard');
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
handleClose();
|
||||
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: 'dashboard.rename-resume',
|
||||
state: {
|
||||
open: true,
|
||||
payload: {
|
||||
item: resume,
|
||||
onComplete: (newResume: Resume) => {
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
router.push(`/${resume.user.username}/${newResume.slug}/build`);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
handleClose();
|
||||
|
||||
const newResume = await duplicateMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
router.push(`/${resume.user.username}/${newResume.slug}/build`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleClose();
|
||||
|
||||
await deleteMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
goBack();
|
||||
};
|
||||
|
||||
const handleShareLink = async () => {
|
||||
handleClose();
|
||||
|
||||
const url = getResumeUrl(resume, { withHost: true });
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar elevation={0} position="fixed">
|
||||
<Toolbar
|
||||
variant="regular"
|
||||
className={cn({
|
||||
[styles.header]: true,
|
||||
[styles.pushLeft]: left.open,
|
||||
[styles.pushRight]: right.open,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={toggleLeftSidebar}>{left.open ? <ChevronLeftIcon /> : <ChevronRightIcon />}</IconButton>
|
||||
|
||||
<div className={styles.title}>
|
||||
<IconButton className="opacity-50 hover:opacity-100" onClick={goBack}>
|
||||
<HomeIcon />
|
||||
</IconButton>
|
||||
|
||||
<span className="opacity-50">{'/'}</span>
|
||||
|
||||
<h1>{name}</h1>
|
||||
|
||||
<IconButton onClick={handleClick}>
|
||||
<KeyboardArrowDownIcon />
|
||||
</IconButton>
|
||||
|
||||
<Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={handleClose}>
|
||||
<MenuItem onClick={handleRename}>
|
||||
<ListItemIcon>
|
||||
<DriveFileRenameOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.rename')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleDuplicate}>
|
||||
<ListItemIcon>
|
||||
<CopyAll className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.duplicate')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{resume.public ? (
|
||||
<MenuItem onClick={handleShareLink}>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<Tooltip arrow placement="right" title={t('builder.header.menu.tooltips.share-link')}>
|
||||
<div>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip arrow placement="right" title={t('builder.header.menu.tooltips.delete')}>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon>
|
||||
<Delete className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<IconButton onClick={toggleRightSidebar}>{right.open ? <ChevronRightIcon /> : <ChevronLeftIcon />}</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -1,43 +0,0 @@
|
||||
.container {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
|
||||
@apply relative z-50 grid shadow;
|
||||
@apply print:shadow-none;
|
||||
|
||||
:global(.printer-mode) & {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
&.break::after {
|
||||
content: 'Page Break';
|
||||
top: calc(297mm - 19px);
|
||||
|
||||
@apply absolute w-full border-b border-dashed border-zinc-900/75;
|
||||
@apply flex items-end justify-end pr-2 pb-0.5 text-xs font-bold text-zinc-900/75;
|
||||
@apply print:hidden;
|
||||
|
||||
:global(.preview-mode) &,
|
||||
:global(.printer-mode) & {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.format-letter {
|
||||
width: 216mm;
|
||||
min-height: 279mm;
|
||||
|
||||
&.break::after {
|
||||
top: calc(279mm - 19px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageNumber {
|
||||
@apply text-center font-bold print:hidden;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { CustomCSS, PageConfig, ThemeConfig, Typography } from 'schema';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import templateMap from '@/templates/templateMap';
|
||||
import { generateThemeStyles, generateTypographyStyles } from '@/utils/styles';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import styles from './Page.module.scss';
|
||||
|
||||
type Props = PageProps & {
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
const Page: React.FC<Props> = ({ page, showPageNumbers = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
const breakLine: boolean = useAppSelector((state) => state.build.page.breakLine);
|
||||
|
||||
const theme: ThemeConfig = get(resume, 'metadata.theme');
|
||||
const customCSS: CustomCSS = get(resume, 'metadata.css');
|
||||
const template: string = get(resume, 'metadata.template');
|
||||
const typography: Typography = get(resume, 'metadata.typography');
|
||||
const pageConfig: PageConfig = get(resume, 'metadata.page', {} as PageConfig);
|
||||
|
||||
const themeCSS = useMemo(() => !isEmpty(theme) && generateThemeStyles(theme), [theme]);
|
||||
const typographyCSS = useMemo(() => !isEmpty(typography) && generateTypographyStyles(typography), [typography]);
|
||||
const TemplatePage: React.FC<PageProps> | null = useMemo(() => templateMap[template].component, [template]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-page={page + 1} data-format={pageConfig?.format || 'A4'}>
|
||||
<div
|
||||
className={clsx({
|
||||
reset: true,
|
||||
[styles.page]: true,
|
||||
[styles.break]: breakLine,
|
||||
[styles['format-letter']]: pageConfig?.format === 'Letter',
|
||||
[css(themeCSS)]: true,
|
||||
[css(typographyCSS)]: true,
|
||||
[css(customCSS.value)]: customCSS.visible,
|
||||
})}
|
||||
>
|
||||
{TemplatePage && <TemplatePage page={page} />}
|
||||
</div>
|
||||
|
||||
{showPageNumbers && <h4 className={styles.pageNumber}>{`${t('builder.common.glossary.page')} ${page + 1}`}</h4>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,45 +0,0 @@
|
||||
.container {
|
||||
@apply h-screen w-[95vw] md:w-[70vw] lg:w-[50vw] xl:w-[30vw] 2xl:w-[28vw];
|
||||
@apply bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100;
|
||||
@apply relative flex border-r-2 border-zinc-100/10;
|
||||
|
||||
nav {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
@apply w-14 py-4 md:w-16 md:px-2;
|
||||
@apply bg-zinc-100 shadow dark:bg-zinc-900;
|
||||
@apply flex flex-col items-center justify-between;
|
||||
|
||||
hr {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
> div {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.sections svg {
|
||||
@apply opacity-75 transition-opacity hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@apply overflow-y-scroll p-4;
|
||||
@apply absolute inset-y-0 left-12 right-0 md:left-16;
|
||||
|
||||
> section {
|
||||
@apply grid gap-4;
|
||||
@apply pt-5 pb-7 first:pt-0;
|
||||
@apply border-b border-zinc-900/10 last:border-b-0 dark:border-zinc-100/10;
|
||||
|
||||
hr {
|
||||
@apply my-2;
|
||||
}
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Add, Star } from '@mui/icons-material';
|
||||
import { Button, Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import get from 'lodash/get';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { ReactComponentElement, useMemo } from 'react';
|
||||
import { Section as SectionRecord } from 'schema';
|
||||
import { validate } from 'uuid';
|
||||
|
||||
import Icon from '@/components/shared/Icon';
|
||||
import { getCustomSections, getSectionsByType, left } from '@/config/sections';
|
||||
import { setSidebarState } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { addSection } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './LeftSidebar.module.scss';
|
||||
import Section from './sections/Section';
|
||||
|
||||
const LeftSidebar = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const sections = useAppSelector((state) => state.resume.present.sections);
|
||||
|
||||
const { open } = useAppSelector((state) => state.build.sidebar.left);
|
||||
|
||||
const customSections = useMemo(() => getCustomSections(sections), [sections]);
|
||||
|
||||
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: true } }));
|
||||
|
||||
const handleClose = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: false } }));
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const elementId = validate(id) ? `#section-${id}` : `#${id}`;
|
||||
const section = document.querySelector(elementId);
|
||||
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
const newSection: SectionRecord = {
|
||||
name: 'Custom Section',
|
||||
type: 'custom',
|
||||
visible: true,
|
||||
columns: 2,
|
||||
items: [],
|
||||
};
|
||||
|
||||
dispatch(addSection({ value: newSection, type: 'custom' }));
|
||||
};
|
||||
|
||||
const sectionsList = () => {
|
||||
const sectionsComponents: Array<ReactComponentElement<any>> = [];
|
||||
|
||||
for (const item of left) {
|
||||
const id = (item as any).id;
|
||||
const component = (item as any).component;
|
||||
const type = component.props.type;
|
||||
const addMore = !!component.props.addMore;
|
||||
|
||||
sectionsComponents.push(
|
||||
<section key={id} id={id}>
|
||||
{component}
|
||||
</section>,
|
||||
);
|
||||
|
||||
if (addMore) {
|
||||
const additionalSections = getSectionsByType(sections, type);
|
||||
const elements = [];
|
||||
for (const element of additionalSections) {
|
||||
const newId = element.id;
|
||||
|
||||
const props = cloneDeep(component.props);
|
||||
props.path = 'sections.' + newId;
|
||||
props.name = element.name;
|
||||
props.isDeletable = true;
|
||||
props.addMore = false;
|
||||
props.isDuplicated = true;
|
||||
const newComponent = React.cloneElement(component, props);
|
||||
|
||||
elements.push(
|
||||
<section key={newId} id={`section-${newId}`}>
|
||||
{newComponent}
|
||||
</section>,
|
||||
);
|
||||
}
|
||||
sectionsComponents.push(...elements);
|
||||
}
|
||||
}
|
||||
|
||||
return sectionsComponents;
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeableDrawer
|
||||
open={open}
|
||||
anchor="left"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ className: '!shadow-lg' }}
|
||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<nav className="overflow-y-auto">
|
||||
<div>
|
||||
<Link href="/dashboard">
|
||||
<IconButton>
|
||||
<Icon size={24} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
{left.map(({ id, icon }) => (
|
||||
<Tooltip
|
||||
arrow
|
||||
key={id}
|
||||
placement="right"
|
||||
title={t(`builder.leftSidebar.sections.${id}.heading`, get(sections, `${id}.name`))}
|
||||
>
|
||||
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{customSections.map(({ id }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
title={t(`builder.leftSidebar.sections.${id}.heading`, get(sections, `${id}.name`))}
|
||||
placement="right"
|
||||
arrow
|
||||
>
|
||||
<IconButton onClick={() => id && handleClick(id)}>
|
||||
<Star />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{sectionsList()}
|
||||
|
||||
{customSections.map(({ id }) => (
|
||||
<section key={id} id={`section-${id}`}>
|
||||
<Section path={`sections.${id}`} type="custom" isEditable isHideable isDeletable />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="py-6 text-right">
|
||||
<Button fullWidth variant="outlined" startIcon={<Add />} onClick={handleAddSection}>
|
||||
{t('builder.common.actions.add', {
|
||||
token: t('builder.leftSidebar.sections.section.heading'),
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SwipeableDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftSidebar;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { PhotoFilter } from '@mui/icons-material';
|
||||
import { Button, Divider, Popover } from '@mui/material';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ResumeInput from '@/components/shared/ResumeInput';
|
||||
|
||||
import PhotoFilters from './PhotoFilters';
|
||||
import PhotoUpload from './PhotoUpload';
|
||||
|
||||
const Basics = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.basics" name={t('builder.leftSidebar.sections.basics.heading')} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid items-center gap-4 sm:col-span-2 sm:grid-cols-3">
|
||||
<div className="mx-auto">
|
||||
<PhotoUpload />
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-2 sm:col-span-2">
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.basics.name.label')} path="basics.name" />
|
||||
|
||||
<Button variant="outlined" startIcon={<PhotoFilter />} onClick={handleClick}>
|
||||
{t('builder.leftSidebar.sections.basics.actions.photo-filters')}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<PhotoFilters />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResumeInput
|
||||
type="date"
|
||||
label={t('builder.leftSidebar.sections.basics.birthdate.label')}
|
||||
path="basics.birthdate"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<ResumeInput label={t('builder.common.form.email.label')} path="basics.email" className="sm:col-span-2" />
|
||||
<ResumeInput label={t('builder.common.form.phone.label')} path="basics.phone" />
|
||||
<ResumeInput label={t('builder.common.form.url.label')} path="basics.website" />
|
||||
|
||||
<Divider className="sm:col-span-2" />
|
||||
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.basics.headline.label')}
|
||||
path="basics.headline"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<ResumeInput
|
||||
type="textarea"
|
||||
label={t('builder.common.form.summary.label')}
|
||||
path="basics.summary"
|
||||
className="sm:col-span-2"
|
||||
markdownSupported
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Basics;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ResumeInput from '@/components/shared/ResumeInput';
|
||||
|
||||
const Location = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.location" name={t('builder.leftSidebar.sections.location.heading')} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.location.address.label')}
|
||||
path="basics.location.address"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.city.label')} path="basics.location.city" />
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.region.label')} path="basics.location.region" />
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.country.label')} path="basics.location.country" />
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.location.postal-code.label')}
|
||||
path="basics.location.postalCode"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Location;
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Circle, Square, SquareRounded } from '@mui/icons-material';
|
||||
import { Checkbox, Divider, FormControlLabel, Slider, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Photo, PhotoShape } from 'schema';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const PhotoFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const photo: Photo = useAppSelector((state) => get(state.resume.present, 'basics.photo'));
|
||||
const size: number = get(photo, 'filters.size', 128);
|
||||
const shape: PhotoShape = get(photo, 'filters.shape', 'square');
|
||||
const grayscale: boolean = get(photo, 'filters.grayscale', false);
|
||||
const border: boolean = get(photo, 'filters.border', false);
|
||||
|
||||
const handleChangeSize = (size: number | number[]) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.size', value: size }));
|
||||
|
||||
const handleChangeShape = (shape: PhotoShape) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.shape', value: shape }));
|
||||
|
||||
const handleSetGrayscale = (value: boolean) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.grayscale', value }));
|
||||
|
||||
const handleSetBorder = (value: boolean) => dispatch(setResumeState({ path: 'basics.photo.filters.border', value }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-5 dark:bg-zinc-900">
|
||||
<div>
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.size.heading')}</h4>
|
||||
|
||||
<div className="mx-2">
|
||||
<Slider
|
||||
min={32}
|
||||
max={512}
|
||||
step={2}
|
||||
marks={[
|
||||
{ value: 32, label: '32' },
|
||||
{ value: 128, label: '128' },
|
||||
{ value: 256, label: '256' },
|
||||
{ value: 512, label: '512' },
|
||||
]}
|
||||
value={size}
|
||||
onChange={(_, value: number | number[]) => handleChangeSize(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.effects.heading')}</h4>
|
||||
|
||||
<div className="flex items-center">
|
||||
<FormControlLabel
|
||||
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.grayscale.label')}
|
||||
control={
|
||||
<Checkbox color="secondary" checked={grayscale} onChange={(_, value) => handleSetGrayscale(value)} />
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.border.label')}
|
||||
control={<Checkbox color="secondary" checked={border} onChange={(_, value) => handleSetBorder(value)} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.shape.heading')}</h4>
|
||||
|
||||
<ToggleButtonGroup exclusive value={shape} onChange={(_, value) => handleChangeShape(value)}>
|
||||
<ToggleButton size="small" value="square" className="w-14">
|
||||
<Square fontSize="small" />
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton size="small" value="rounded-square" className="w-14">
|
||||
<SquareRounded fontSize="small" />
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton size="small" value="circle" className="w-14">
|
||||
<Circle fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoFilters;
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Avatar, IconButton, Skeleton, Tooltip } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Photo, Resume } from 'schema';
|
||||
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { deletePhoto, DeletePhotoParams, uploadPhoto, UploadPhotoParams } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const FILE_UPLOAD_MAX_SIZE = 2000000; // 2 MB
|
||||
|
||||
const PhotoUpload: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const id: number = useAppSelector((state) => get(state.resume.present, 'id'));
|
||||
const photo: Photo = useAppSelector((state) => get(state.resume.present, 'basics.photo'));
|
||||
|
||||
const { mutateAsync: uploadMutation, isLoading } = useMutation<Resume, ServerError, UploadPhotoParams>(uploadPhoto);
|
||||
|
||||
const { mutateAsync: deleteMutation } = useMutation<Resume, ServerError, DeletePhotoParams>(deletePhoto);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (fileInputRef.current) {
|
||||
if (!isEmpty(photo.url)) {
|
||||
try {
|
||||
await deleteMutation({ id });
|
||||
} finally {
|
||||
dispatch(setResumeState({ path: 'basics.photo.url', value: '' }));
|
||||
}
|
||||
} else {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file.size > FILE_UPLOAD_MAX_SIZE) {
|
||||
toast.error(t('common.toast.error.upload-photo-size'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resume = await uploadMutation({ id, file });
|
||||
|
||||
dispatch(setResumeState({ path: 'basics.photo.url', value: get(resume, 'basics.photo.url', '') }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="circular" width={96} height={96} />
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
isEmpty(photo.url)
|
||||
? (t('builder.leftSidebar.sections.basics.photo-upload.tooltip.upload') as string)
|
||||
: (t('builder.leftSidebar.sections.basics.photo-upload.tooltip.remove') as string)
|
||||
}
|
||||
>
|
||||
<Avatar sx={{ width: 96, height: 96 }} src={photo.url} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<input hidden type="file" ref={fileInputRef} onChange={handleChange} accept="image/*" />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoUpload;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ListItem } from 'schema';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import List from '@/components/shared/List';
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { duplicateItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
const Profiles = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleAdd = () => {
|
||||
dispatch(setModalState({ modal: 'builder.sections.profile', state: { open: true } }));
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItem) => {
|
||||
dispatch(setModalState({ modal: 'builder.sections.profile', state: { open: true, payload: { item } } }));
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItem) => {
|
||||
dispatch(duplicateItem({ path: 'basics.profiles', value: item }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.profiles" name={t('builder.leftSidebar.sections.profiles.heading')} />
|
||||
|
||||
<List
|
||||
path="basics.profiles"
|
||||
titleKey="username"
|
||||
subtitleKey="network"
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
|
||||
<footer className="flex justify-end">
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAdd}>
|
||||
{t('builder.common.actions.add', {
|
||||
token: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profiles;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ListItem, Section as SectionRecord, SectionType } from 'schema';
|
||||
import { validate } from 'uuid';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import List from '@/components/shared/List';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { ModalName, setModalState } from '@/store/modal/modalSlice';
|
||||
import { duplicateItem, duplicateSection } from '@/store/resume/resumeSlice';
|
||||
|
||||
import SectionSettings from './SectionSettings';
|
||||
|
||||
type Props = {
|
||||
path: `sections.${string}`;
|
||||
type?: SectionType;
|
||||
name?: string;
|
||||
titleKey?: string;
|
||||
subtitleKey?: string;
|
||||
isEditable?: boolean;
|
||||
isHideable?: boolean;
|
||||
isDeletable?: boolean;
|
||||
addMore?: boolean;
|
||||
isDuplicated?: boolean;
|
||||
};
|
||||
|
||||
const Section: React.FC<Props> = ({
|
||||
path,
|
||||
name = 'Section Name',
|
||||
type = 'basic',
|
||||
titleKey = 'title',
|
||||
subtitleKey = 'subtitle',
|
||||
isEditable = false,
|
||||
isHideable = false,
|
||||
isDeletable = false,
|
||||
addMore = false,
|
||||
isDuplicated = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector<string>((state) => get(state.resume.present, `${path}.name`, name));
|
||||
const visibility = useAppSelector<boolean>((state) => get(state.resume.present, `${path}.visible`, true));
|
||||
|
||||
const handleAdd = () => {
|
||||
const modal: ModalName = `builder.sections.${type}`;
|
||||
|
||||
dispatch(setModalState({ modal, state: { open: true, payload: { path } } }));
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItem) => {
|
||||
const id = path.split('.')[1];
|
||||
let modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
|
||||
|
||||
const payload = validate(id) ? { path, item } : { item };
|
||||
|
||||
if (isDuplicated) {
|
||||
modal = `builder.sections.${type}`;
|
||||
payload.path = path;
|
||||
}
|
||||
|
||||
dispatch(setModalState({ modal, state: { open: true, payload } }));
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItem) => dispatch(duplicateItem({ path: `${path}.items`, value: item }));
|
||||
|
||||
const handleDuplicateSection = () => {
|
||||
const newSection: SectionRecord = {
|
||||
name: `${heading}`,
|
||||
type: type,
|
||||
visible: true,
|
||||
columns: 2,
|
||||
items: [],
|
||||
isDuplicated: true,
|
||||
};
|
||||
|
||||
dispatch(duplicateSection({ value: newSection, type }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path={path} name={name} isEditable={isEditable} isHideable={isHideable} isDeletable={isDeletable} />
|
||||
|
||||
<List
|
||||
path={`${path}.items`}
|
||||
titleKey={titleKey}
|
||||
subtitleKey={subtitleKey}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
className={clsx({ 'opacity-50': !visibility })}
|
||||
/>
|
||||
|
||||
<footer className="flex items-center justify-between">
|
||||
<SectionSettings path={path} />
|
||||
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAdd}>
|
||||
{t('builder.common.actions.add', {
|
||||
token: t(`builder.leftSidebar.${path}.heading`, { defaultValue: heading }),
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
{addMore ? (
|
||||
<div className="py-6 text-right">
|
||||
<Button fullWidth variant="outlined" startIcon={<Add />} onClick={handleDuplicateSection}>
|
||||
{t('builder.common.actions.duplicate')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ViewWeek } from '@mui/icons-material';
|
||||
import { ButtonBase, Popover, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
const SectionSettings: React.FC<Props> = ({ path }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const columns = useAppSelector<number>((state) => get(state.resume.present, `${path}.columns`, 2));
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSetColumns = (index: number) => dispatch(setResumeState({ path: `${path}.columns`, value: index }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip title={t('builder.common.columns.tooltip')}>
|
||||
<ButtonBase onClick={handleClick} sx={{ padding: 1, borderRadius: 1 }} className="opacity-50 hover:opacity-75">
|
||||
<ViewWeek /> <span className="ml-1.5 text-xs">{columns}</span>
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<div className="p-5 dark:bg-zinc-900">
|
||||
<h4 className="mb-2 font-medium">{t('builder.common.columns.heading')}</h4>
|
||||
|
||||
<ToggleButtonGroup exclusive value={columns} onChange={(_, value: number) => handleSetColumns(value)}>
|
||||
{[1, 2, 3, 4].map((index) => (
|
||||
<ToggleButton key={index} value={index} size="small" className="w-12">
|
||||
{index}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionSettings;
|
||||
@@ -1,50 +0,0 @@
|
||||
.container {
|
||||
@apply h-screen w-[95vw] md:w-[70vw] lg:w-[50vw] xl:w-[30vw] 2xl:w-[28vw];
|
||||
@apply bg-zinc-50 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50;
|
||||
@apply relative flex border-l-2 border-zinc-50/10;
|
||||
|
||||
nav {
|
||||
@apply absolute inset-y-0 right-0;
|
||||
@apply w-12 py-4 md:w-16 md:px-2;
|
||||
@apply bg-zinc-100 shadow dark:bg-zinc-900;
|
||||
@apply flex flex-col items-center justify-between;
|
||||
|
||||
hr {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
> div {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.sections svg {
|
||||
@apply opacity-75 transition-opacity hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@apply overflow-y-scroll p-4;
|
||||
@apply absolute inset-y-0 right-12 left-0 md:right-16;
|
||||
|
||||
> section {
|
||||
@apply grid gap-4;
|
||||
@apply pt-5 pb-7 first:pt-0;
|
||||
@apply border-b border-zinc-900/10 last:border-b-0 dark:border-zinc-50/10;
|
||||
|
||||
hr {
|
||||
@apply my-2;
|
||||
}
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply flex flex-col items-center justify-center gap-3 py-4;
|
||||
@apply text-center text-xs leading-normal opacity-40;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Avatar from '@/components/shared/Avatar';
|
||||
import Footer from '@/components/shared/Footer';
|
||||
import { right } from '@/config/sections';
|
||||
import { setSidebarState } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
|
||||
import styles from './RightSidebar.module.scss';
|
||||
|
||||
const RightSidebar = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const { open } = useAppSelector((state) => state.build.sidebar.right);
|
||||
|
||||
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: true } }));
|
||||
|
||||
const handleClose = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: false } }));
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const section = document.querySelector(`#${id}`);
|
||||
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeableDrawer
|
||||
open={open}
|
||||
anchor="right"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ className: '!shadow-lg' }}
|
||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<nav className="overflow-y-auto">
|
||||
<div>
|
||||
<Avatar size={24} />
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
{right.map(({ id, icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
arrow
|
||||
placement="right"
|
||||
title={t(`builder.rightSidebar.sections.${id}.heading`, { defaultValue: capitalize(id) })}
|
||||
>
|
||||
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{right.map(({ id, component }) => (
|
||||
<section key={id} id={id}>
|
||||
{component}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<Footer />
|
||||
|
||||
<div>v{process.env.appVersion}</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</SwipeableDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightSidebar;
|
||||
@@ -1,51 +0,0 @@
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { useTheme } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React from 'react';
|
||||
import { CustomCSS as CustomCSSType } from 'schema';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const CustomCSS = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const customCSS: CustomCSSType = useAppSelector((state) =>
|
||||
get(state.resume.present, 'metadata.css', {} as CustomCSSType),
|
||||
);
|
||||
|
||||
const handleChange = (value: string | undefined) => {
|
||||
dispatch(setResumeState({ path: 'metadata.css.value', value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.css" name={t('builder.rightSidebar.sections.css.heading')} isHideable />
|
||||
|
||||
<Editor
|
||||
height="200px"
|
||||
language="css"
|
||||
value={customCSS.value}
|
||||
onChange={handleChange}
|
||||
className={clsx({ 'opacity-50': !customCSS.visible })}
|
||||
theme={theme.palette.mode === 'dark' ? 'vs-dark' : 'light'}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
overviewRulerLanes: 0,
|
||||
scrollBeyondLastColumn: 5,
|
||||
overviewRulerBorder: false,
|
||||
scrollBeyondLastLine: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCSS;
|
||||
@@ -1,84 +0,0 @@
|
||||
import { PictureAsPdf, Schema } from '@mui/icons-material';
|
||||
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import get from 'lodash/get';
|
||||
import pick from 'lodash/pick';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Export = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
|
||||
|
||||
const pdfListItemText = {
|
||||
normal: {
|
||||
primary: t('builder.rightSidebar.sections.export.pdf.normal.primary'),
|
||||
secondary: t('builder.rightSidebar.sections.export.pdf.normal.secondary'),
|
||||
},
|
||||
loading: {
|
||||
primary: t('builder.rightSidebar.sections.export.pdf.loading.primary'),
|
||||
secondary: t('builder.rightSidebar.sections.export.pdf.loading.secondary'),
|
||||
},
|
||||
};
|
||||
|
||||
const handleExportJSON = async () => {
|
||||
const nanoid = (await import('nanoid')).nanoid;
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const redactedResume = pick(resume, ['basics', 'sections', 'metadata', 'public']);
|
||||
const jsonString = JSON.stringify(redactedResume, null, 4);
|
||||
const jsonBlob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
|
||||
const filename = `RxResume_JSONExport_${nanoid()}.json`;
|
||||
|
||||
download(jsonBlob, filename);
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const slug = get(resume, 'slug');
|
||||
const username = get(resume, 'user.username');
|
||||
const updatedAt = get(resume, 'updatedAt');
|
||||
|
||||
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
|
||||
|
||||
download(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.export" name={t('builder.rightSidebar.sections.export.heading')} />
|
||||
|
||||
<List sx={{ padding: 0 }}>
|
||||
<ListItem sx={{ padding: 0 }}>
|
||||
<ListItemButton className="gap-6" onClick={handleExportJSON}>
|
||||
<Schema />
|
||||
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.export.json.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.export.json.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem sx={{ padding: 0 }}>
|
||||
<ListItemButton className="gap-6" onClick={handleExportPDF} disabled={isLoading}>
|
||||
<PictureAsPdf />
|
||||
|
||||
<ListItemText {...(isLoading ? pdfListItemText.loading : pdfListItemText.normal)} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Export;
|
||||
@@ -1,43 +0,0 @@
|
||||
.page {
|
||||
@apply relative border pl-4 pb-4 dark:border-zinc-100/10;
|
||||
@apply rounded bg-zinc-100 dark:bg-zinc-900;
|
||||
|
||||
.delete {
|
||||
@apply opacity-50 hover:opacity-75;
|
||||
@apply rotate-0 hover:rotate-90;
|
||||
@apply transition-[opacity,transform];
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid grid-cols-2 gap-2;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply relative z-10 my-3;
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
@apply relative w-full px-4;
|
||||
|
||||
.heading {
|
||||
@apply relative z-10 my-3;
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
|
||||
.base {
|
||||
@apply absolute inset-0 w-4/5;
|
||||
@apply rounded bg-zinc-200 dark:bg-zinc-800;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply relative my-3 w-full px-4 py-2;
|
||||
@apply cursor-move break-all rounded text-xs capitalize;
|
||||
@apply bg-zinc-900/90 text-zinc-50 dark:bg-zinc-50/90 dark:text-zinc-900;
|
||||
|
||||
&.disabled {
|
||||
@apply opacity-60;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { DragDropContext, Draggable, DraggableLocation, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||
import { Add, Close, Restore } from '@mui/icons-material';
|
||||
import { Button, IconButton, Tooltip } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { addPage, deletePage, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Layout.module.scss';
|
||||
|
||||
const getIndices = (location: DraggableLocation) => ({
|
||||
page: +location.droppableId.split('.')[0],
|
||||
column: +location.droppableId.split('.')[1],
|
||||
section: +location.index,
|
||||
});
|
||||
|
||||
const Layout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const layout = useAppSelector((state) => state.resume.present.metadata.layout);
|
||||
const resumeSections = useAppSelector((state) => state.resume.present.sections);
|
||||
|
||||
const onDragEnd = (dropResult: DropResult) => {
|
||||
const { source: srcLoc, destination: destLoc } = dropResult;
|
||||
|
||||
if (!destLoc) return;
|
||||
|
||||
const newLayout = cloneDeep(layout);
|
||||
|
||||
const srcIndex = getIndices(srcLoc);
|
||||
const destIndex = getIndices(destLoc);
|
||||
const section = layout[srcIndex.page][srcIndex.column][srcIndex.section];
|
||||
|
||||
// Remove item at source
|
||||
newLayout[srcIndex.page][srcIndex.column].splice(srcIndex.section, 1);
|
||||
|
||||
// Insert item at destination
|
||||
newLayout[destIndex.page][destIndex.column].splice(destIndex.section, 0, section);
|
||||
|
||||
dispatch(setResumeState({ path: 'metadata.layout', value: newLayout }));
|
||||
};
|
||||
|
||||
const handleAddPage = () => dispatch(addPage());
|
||||
|
||||
const handleDeletePage = (page: number) => dispatch(deletePage({ page }));
|
||||
|
||||
const handleResetLayout = () => {
|
||||
for (let i = layout.length - 1; i > 0; i--) {
|
||||
handleDeletePage(i);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading
|
||||
path="metadata.layout"
|
||||
name={t('builder.rightSidebar.sections.layout.heading')}
|
||||
action={
|
||||
<Tooltip title={t('builder.rightSidebar.sections.layout.tooltip.reset-layout')}>
|
||||
<IconButton onClick={handleResetLayout}>
|
||||
<Restore />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
{/* Pages */}
|
||||
{layout.map((columns, pageIndex) => (
|
||||
<div key={pageIndex} className={styles.page}>
|
||||
<div className="flex items-center justify-between pr-3">
|
||||
<p className={styles.heading}>
|
||||
{t('builder.common.glossary.page')} {pageIndex + 1}
|
||||
</p>
|
||||
|
||||
<div className={clsx(styles.delete, { hidden: pageIndex === 0 })}>
|
||||
<Tooltip
|
||||
title={
|
||||
t('builder.common.actions.delete', {
|
||||
token: t('builder.common.glossary.page'),
|
||||
}) as string
|
||||
}
|
||||
>
|
||||
<IconButton size="small" onClick={() => handleDeletePage(pageIndex)}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
{/* Sections */}
|
||||
{columns.map((sections, columnIndex) => {
|
||||
const index = `${pageIndex}.${columnIndex}`;
|
||||
|
||||
return (
|
||||
<Droppable key={index} droppableId={index}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} className={styles.column} {...provided.droppableProps}>
|
||||
<p className={styles.heading}>{columnIndex ? 'Sidebar' : 'Main'}</p>
|
||||
|
||||
<div className={styles.base} />
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((sectionId, sectionIndex) => (
|
||||
<Draggable key={sectionId} draggableId={sectionId} index={sectionIndex}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
<div
|
||||
className={clsx(styles.section, {
|
||||
[styles.disabled]: !get(resumeSections, `${sectionId}.visible`, true),
|
||||
})}
|
||||
>
|
||||
{get(resumeSections, `${sectionId}.name`, '') as string}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAddPage}>
|
||||
{t('builder.common.actions.add', { token: t('builder.common.glossary.page') })}
|
||||
</Button>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,20 +0,0 @@
|
||||
.container {
|
||||
@apply grid gap-4;
|
||||
|
||||
.section {
|
||||
@apply grid gap-2 rounded p-6;
|
||||
@apply bg-zinc-100 dark:bg-zinc-900;
|
||||
|
||||
h2 {
|
||||
@apply inline-flex items-center gap-2 text-base font-medium;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-3 text-xs leading-loose;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply hover:no-underline;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { BugReport, Coffee, GitHub, Link, Savings } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { DOCS_URL, DONATION_URL, GITHUB_ISSUES_URL, GITHUB_URL, REDDIT_URL } from '@/constants/index';
|
||||
|
||||
import styles from './Links.module.scss';
|
||||
|
||||
const Links = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.links" name={t('builder.rightSidebar.sections.links.heading')} />
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<h2>
|
||||
<Savings fontSize="small" />
|
||||
{t('builder.rightSidebar.sections.links.donate.heading')}
|
||||
</h2>
|
||||
|
||||
<p>{t('builder.rightSidebar.sections.links.donate.body')}</p>
|
||||
|
||||
<a href={DONATION_URL} target="_blank" rel="noreferrer">
|
||||
<Button startIcon={<Coffee />}>{t('builder.rightSidebar.sections.links.donate.button')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h2>
|
||||
<BugReport fontSize="small" />
|
||||
{t('builder.rightSidebar.sections.links.bugs-features.heading')}
|
||||
</h2>
|
||||
|
||||
<p>{t('builder.rightSidebar.sections.links.bugs-features.body')}</p>
|
||||
|
||||
<a href={GITHUB_ISSUES_URL} target="_blank" rel="noreferrer">
|
||||
<Button startIcon={<GitHub />}>{t('builder.rightSidebar.sections.links.bugs-features.button')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href={GITHUB_URL} target="_blank" rel="noreferrer">
|
||||
<Button variant="text" startIcon={<Link />}>
|
||||
{t('builder.rightSidebar.sections.links.github')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<a href={REDDIT_URL} target="_blank" rel="noreferrer">
|
||||
<Button variant="text" startIcon={<Link />}>
|
||||
{t('builder.rightSidebar.sections.links.reddit')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<a href={DOCS_URL} target="_blank" rel="noreferrer">
|
||||
<Button variant="text" startIcon={<Link />}>
|
||||
{t('builder.rightSidebar.sections.links.docs')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Links;
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Anchor, DeleteForever, Palette } from '@mui/icons-material';
|
||||
import {
|
||||
Autocomplete,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import get from 'lodash/get';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { DateConfig, PageConfig, Resume } from 'schema';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ThemeSwitch from '@/components/shared/ThemeSwitch';
|
||||
import { Language, languageMap, languages } from '@/config/languages';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { loadSampleData, LoadSampleDataParams, resetResume, ResetResumeParams } from '@/services/resume';
|
||||
import { setTheme, togglePageBreakLine, togglePageOrientation } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import { dateFormatOptions } from '@/utils/date';
|
||||
|
||||
const Settings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { locale, ...router } = useRouter();
|
||||
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
const theme = useAppSelector((state) => state.build.theme);
|
||||
const pages = useAppSelector((state) => state.resume.present.metadata.layout);
|
||||
const breakLine = useAppSelector((state) => state.build.page.breakLine);
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const id: number = useMemo(() => get(resume, 'id'), [resume]);
|
||||
const slug: string = useMemo(() => get(resume, 'slug'), [resume]);
|
||||
const username: string = useMemo(() => get(resume, 'user.username'), [resume]);
|
||||
const dateConfig: DateConfig = useMemo(() => get(resume, 'metadata.date'), [resume]);
|
||||
const pageConfig: PageConfig | undefined = useMemo(() => get(resume, 'metadata.page'), [resume]);
|
||||
|
||||
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
|
||||
const exampleDateString = useMemo(() => `Eg. ${dayjs().format(dateConfig.format)}`, [dateConfig.format]);
|
||||
const themeString = useMemo(() => (isDarkMode ? 'Matte Black Everything' : 'As bright as your future'), [isDarkMode]);
|
||||
|
||||
const { mutateAsync: loadSampleDataMutation } = useMutation<Resume, ServerError, LoadSampleDataParams>(
|
||||
loadSampleData,
|
||||
);
|
||||
const { mutateAsync: resetResumeMutation } = useMutation<Resume, ServerError, ResetResumeParams>(resetResume);
|
||||
|
||||
const handleSetTheme = (value: boolean) => dispatch(setTheme({ theme: value ? 'dark' : 'light' }));
|
||||
|
||||
const handleChangePageFormat = (value: PageConfig['format'] | null) =>
|
||||
dispatch(setResumeState({ path: 'metadata.page.format', value }));
|
||||
|
||||
const handleChangeDateFormat = (value: string | null) =>
|
||||
dispatch(setResumeState({ path: 'metadata.date.format', value }));
|
||||
|
||||
const handleChangeLanguage = (value: Language | null) => {
|
||||
const { pathname, asPath, query, push } = router;
|
||||
const code = value?.code || 'en';
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${code}; path=/; expires=2147483647`;
|
||||
dispatch(setResumeState({ path: 'metadata.locale', value: code }));
|
||||
|
||||
push({ pathname, query }, asPath, { locale: code });
|
||||
};
|
||||
|
||||
const handleLoadSampleData = async () => {
|
||||
await loadSampleDataMutation({ id });
|
||||
|
||||
queryClient.invalidateQueries(`resume/${username}/${slug}`);
|
||||
};
|
||||
|
||||
const handleResetResume = async () => {
|
||||
if (!confirmReset) {
|
||||
return setConfirmReset(true);
|
||||
}
|
||||
|
||||
await resetResumeMutation({ id });
|
||||
await queryClient.invalidateQueries(`resume/${username}/${slug}`);
|
||||
|
||||
setConfirmReset(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.settings" name={t('builder.rightSidebar.sections.settings.heading')} />
|
||||
|
||||
<List disablePadding>
|
||||
{/* Global Settings */}
|
||||
<>
|
||||
<ListSubheader disableSticky className="rounded">
|
||||
{t('builder.rightSidebar.sections.settings.global.heading')}
|
||||
</ListSubheader>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Palette />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.global.theme.primary')}
|
||||
secondary={themeString}
|
||||
/>
|
||||
<ThemeSwitch checked={isDarkMode} onChange={(_, value: boolean) => handleSetTheme(value)} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem className="flex-col">
|
||||
<ListItemText
|
||||
className="w-full"
|
||||
primary={t('builder.rightSidebar.sections.settings.global.date.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.global.date.secondary')}
|
||||
/>
|
||||
<Autocomplete<string, false, true, false>
|
||||
disableClearable
|
||||
className="my-2 w-full"
|
||||
options={dateFormatOptions}
|
||||
value={dateConfig.format}
|
||||
onChange={(_, value) => handleChangeDateFormat(value)}
|
||||
renderInput={(params) => <TextField {...params} helperText={exampleDateString} />}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem className="flex-col">
|
||||
<ListItemText
|
||||
className="w-full"
|
||||
primary={t('builder.rightSidebar.sections.settings.global.language.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.global.language.secondary')}
|
||||
/>
|
||||
<Autocomplete<Language, false, true, false>
|
||||
disableClearable
|
||||
className="my-2 w-full"
|
||||
options={languages}
|
||||
value={languageMap[locale ?? 'en']}
|
||||
isOptionEqualToValue={(a, b) => a.code === b.code}
|
||||
onChange={(_, value) => handleChangeLanguage(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
getOptionLabel={(language) => {
|
||||
if (language.localName) {
|
||||
return `${language.name} (${language.localName})`;
|
||||
}
|
||||
|
||||
return language.name;
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
|
||||
{/* Page Settings */}
|
||||
<>
|
||||
<ListSubheader disableSticky className="rounded">
|
||||
{t('builder.rightSidebar.sections.settings.page.heading')}
|
||||
</ListSubheader>
|
||||
|
||||
<ListItem className="flex-col">
|
||||
<ListItemText
|
||||
className="w-full"
|
||||
primary={t('builder.rightSidebar.sections.settings.page.format.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.page.format.secondary')}
|
||||
/>
|
||||
<Autocomplete<PageConfig['format'], false, true, false>
|
||||
disableClearable
|
||||
defaultValue="A4"
|
||||
className="my-2 w-full"
|
||||
options={['A4', 'Letter']}
|
||||
value={pageConfig?.format || 'A4'}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
onChange={(_, value) => handleChangePageFormat(value)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.page.orientation.primary')}
|
||||
secondary={
|
||||
pages.length === 1
|
||||
? t('builder.rightSidebar.sections.settings.page.orientation.disabled')
|
||||
: t('builder.rightSidebar.sections.settings.page.orientation.secondary')
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
color="secondary"
|
||||
disabled={pages.length === 1}
|
||||
checked={orientation === 'horizontal'}
|
||||
onChange={() => dispatch(togglePageOrientation())}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.page.break-line.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.page.break-line.secondary')}
|
||||
/>
|
||||
<Switch color="secondary" checked={breakLine} onChange={() => dispatch(togglePageBreakLine())} />
|
||||
</ListItem>
|
||||
</>
|
||||
|
||||
{/* Resume Settings */}
|
||||
<>
|
||||
<ListSubheader disableSticky className="rounded">
|
||||
{t('builder.rightSidebar.sections.settings.resume.heading')}
|
||||
</ListSubheader>
|
||||
|
||||
<ListItem disableGutters>
|
||||
<ListItemButton onClick={handleLoadSampleData}>
|
||||
<ListItemIcon>
|
||||
<Anchor />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.resume.sample.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.resume.sample.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem disableGutters>
|
||||
<ListItemButton onClick={handleResetResume}>
|
||||
<ListItemIcon>
|
||||
<DeleteForever />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
confirmReset ? 'Are you sure?' : t('builder.rightSidebar.sections.settings.resume.reset.primary')
|
||||
}
|
||||
secondary={t('builder.rightSidebar.sections.settings.resume.reset.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { CopyAll } from '@mui/icons-material';
|
||||
import { Checkbox, FormControlLabel, IconButton, List, ListItem, ListItemText, Switch, TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
|
||||
const Sharing = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [showShortUrl, setShowShortUrl] = useState(false);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume.present);
|
||||
const isPublic = useMemo(() => get(resume, 'public'), [resume]);
|
||||
const url = useMemo(() => getResumeUrl(resume, { withHost: true }), [resume]);
|
||||
const shortUrl = useMemo(() => getResumeUrl(resume, { withHost: true, shortUrl: true }), [resume]);
|
||||
|
||||
const handleSetVisibility = (value: boolean) => dispatch(setResumeState({ path: 'public', value }));
|
||||
|
||||
const handleCopyToClipboard = async () => {
|
||||
const text = showShortUrl ? shortUrl : url;
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.sharing" name={t('builder.rightSidebar.sections.sharing.heading')} />
|
||||
|
||||
<List sx={{ padding: 0 }}>
|
||||
<ListItem className="flex flex-col" sx={{ padding: 0 }}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.sharing.visibility.title')}
|
||||
secondary={t('builder.rightSidebar.sections.sharing.visibility.subtitle')}
|
||||
/>
|
||||
<Switch color="secondary" checked={isPublic} onChange={(_, value) => handleSetVisibility(value)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-full">
|
||||
<TextField
|
||||
disabled
|
||||
fullWidth
|
||||
value={showShortUrl ? shortUrl : url}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={handleCopyToClipboard}>
|
||||
<CopyAll />
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex w-full">
|
||||
<FormControlLabel
|
||||
label={t('builder.rightSidebar.sections.sharing.short-url.label')}
|
||||
control={
|
||||
<Checkbox className="mr-1" checked={showShortUrl} onChange={(_, value) => setShowShortUrl(value)} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sharing;
|
||||
@@ -1,22 +0,0 @@
|
||||
.container {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.template {
|
||||
@apply grid text-center;
|
||||
|
||||
.preview {
|
||||
aspect-ratio: 1 / 1.4142;
|
||||
|
||||
@apply relative grid rounded;
|
||||
@apply border-2 border-transparent;
|
||||
|
||||
&.selected {
|
||||
@apply border-black dark:border-white;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mt-1 text-xs font-medium;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ButtonBase } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import templateMap, { TemplateMeta } from '@/templates/templateMap';
|
||||
|
||||
import styles from './Templates.module.scss';
|
||||
|
||||
const Templates = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const currentTemplate: string = useAppSelector((state) => get(state.resume.present, 'metadata.template'));
|
||||
|
||||
const handleChange = (template: TemplateMeta) => {
|
||||
dispatch(setResumeState({ path: 'metadata.template', value: template.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.templates" name={t('builder.rightSidebar.sections.templates.heading')} />
|
||||
|
||||
<div className={styles.container}>
|
||||
{Object.values(templateMap).map((template) => (
|
||||
<div key={template.id} className={styles.template}>
|
||||
<div className={clsx(styles.preview, { [styles.selected]: template.id === currentTemplate })}>
|
||||
<ButtonBase onClick={() => handleChange(template)}>
|
||||
<Image
|
||||
fill
|
||||
priority
|
||||
alt={template.name}
|
||||
src={template.preview}
|
||||
className="rounded-sm"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
</ButtonBase>
|
||||
</div>
|
||||
|
||||
<p className={styles.label}>{template.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Templates;
|
||||