[REPO REFACTOR]: changed to a better git repository structure with branches
This commit is contained in:
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title style="font-family: Fira Code;">mpaste</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="p-0 m-0"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "miarma-base",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"axios": "^1.11.0",
|
||||
"bootstrap": "^5.3.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"react-slick": "^0.30.3",
|
||||
"slick-carousel": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"electron": "^37.2.5",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
12
public/config/settings.dev.json
Normal file
12
public/config/settings.dev.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "https://api.miarma.net/mpaste",
|
||||
"endpoints": {
|
||||
"pastes": {
|
||||
"all": "/raw/v1/pastes",
|
||||
"byId": "/raw/v1/pastes/:paste_id",
|
||||
"byKey": "/v1/pastes/:paste_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
public/config/settings.prod.json
Normal file
12
public/config/settings.prod.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "https://api.miarma.net/mpaste",
|
||||
"endpoints": {
|
||||
"pastes": {
|
||||
"all": "/raw/v1/pastes",
|
||||
"byId": "/raw/v1/pastes/:paste_id",
|
||||
"byKey": "/v1/pastes/:paste_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/fonts/FiraCode.ttf
Normal file
BIN
public/fonts/FiraCode.ttf
Normal file
Binary file not shown.
BIN
public/fonts/OpenSans.ttf
Normal file
BIN
public/fonts/OpenSans.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansBold.ttf
Normal file
BIN
public/fonts/ProductSansBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansBoldItalic.ttf
Normal file
BIN
public/fonts/ProductSansBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansItalic.ttf
Normal file
BIN
public/fonts/ProductSansItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansRegular.ttf
Normal file
BIN
public/fonts/ProductSansRegular.ttf
Normal file
Binary file not shown.
BIN
public/images/bg.png
Normal file
BIN
public/images/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
389
public/images/favicon.svg
Normal file
389
public/images/favicon.svg
Normal file
@@ -0,0 +1,389 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="128px"
|
||||
viewBox="0 0 128 128"
|
||||
width="128px"
|
||||
version="1.1"
|
||||
id="svg83"
|
||||
xml:space="preserve"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs87"><clipPath
|
||||
id="b-5"><path
|
||||
d="m 88,94 h 20 v 20 H 88 Z m 0,0"
|
||||
id="path1370" /></clipPath><clipPath
|
||||
id="c-3"><path
|
||||
d="M 108,94 88,114 H 82.285156 V 88.285156 H 108 Z m 0,0"
|
||||
id="path1373" /></clipPath><linearGradient
|
||||
xlink:href="#d"
|
||||
id="linearGradient1633"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0,0.178571,0.178571,0,84.428574,42.571447)"
|
||||
x1="344"
|
||||
y1="76"
|
||||
x2="340"
|
||||
y2="72" /><linearGradient
|
||||
y2="204"
|
||||
x2="45.963043"
|
||||
y1="204"
|
||||
x1="461.96304"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="linearGradient973"
|
||||
xlink:href="#linearGradient987"
|
||||
gradientTransform="matrix(0.25,0,0,0.25,0.50924,53.119749)" /><linearGradient
|
||||
id="linearGradient987"><stop
|
||||
id="stop975"
|
||||
offset="0"
|
||||
style="stop-color:#a29890;stop-opacity:1;" /><stop
|
||||
style="stop-color:#f6f5f4;stop-opacity:1;"
|
||||
offset="0.04166667"
|
||||
id="stop977" /><stop
|
||||
id="stop979"
|
||||
offset="0.08333334"
|
||||
style="stop-color:#dddcd9;stop-opacity:1;" /><stop
|
||||
style="stop-color:#c6c3be;stop-opacity:1;"
|
||||
offset="0.91666669"
|
||||
id="stop981" /><stop
|
||||
id="stop983"
|
||||
offset="0.95833331"
|
||||
style="stop-color:#f6f5f4;stop-opacity:1;" /><stop
|
||||
id="stop985"
|
||||
offset="1"
|
||||
style="stop-color:#a29890;stop-opacity:1;" /></linearGradient><radialGradient
|
||||
r="48"
|
||||
fy="151.00006"
|
||||
fx="405"
|
||||
cy="151.00006"
|
||||
cx="405"
|
||||
gradientTransform="matrix(1.4599987,-1.4599987,0.02960147,0.02960147,-490.51932,677.69942)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient902"
|
||||
xlink:href="#linearGradient897" /><linearGradient
|
||||
id="linearGradient897"><stop
|
||||
id="stop893"
|
||||
offset="0"
|
||||
style="stop-color:#dfdbd8;stop-opacity:1;" /><stop
|
||||
id="stop895"
|
||||
offset="1"
|
||||
style="stop-color:#ffffff;stop-opacity:1;" /></linearGradient><clipPath
|
||||
id="clipPath2213"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect2215"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath><clipPath
|
||||
id="clipPath472"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect470"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath><clipPath
|
||||
id="clipPath476"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect474"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath><clipPath
|
||||
id="clipPath480"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect478"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath><clipPath
|
||||
id="clipPath484"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect482"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath><clipPath
|
||||
id="clipPath488"
|
||||
clipPathUnits="userSpaceOnUse"><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="-188"
|
||||
x="160"
|
||||
height="56"
|
||||
width="16"
|
||||
id="rect486"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /></clipPath></defs><linearGradient
|
||||
id="a"
|
||||
gradientTransform="matrix(-1.1818182,0,0,-1.0170261,319.63995,-177.72506)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="27.99999"
|
||||
x2="115.999992"
|
||||
y1="-276"
|
||||
y2="-276"><stop
|
||||
offset="0"
|
||||
stop-color="#c0bfbc"
|
||||
id="stop2" /><stop
|
||||
offset="0.0454545"
|
||||
stop-color="#ffffff"
|
||||
id="stop4" /><stop
|
||||
offset="0.0909091"
|
||||
stop-color="#deddda"
|
||||
id="stop6" /><stop
|
||||
offset="0.909091"
|
||||
stop-color="#deddda"
|
||||
id="stop8" /><stop
|
||||
offset="0.954545"
|
||||
stop-color="#ffffff"
|
||||
id="stop10" /><stop
|
||||
offset="1"
|
||||
stop-color="#c0bfbc"
|
||||
id="stop12" /></linearGradient><clipPath
|
||||
id="b"><path
|
||||
d="m 88 94 h 20 v 20 h -20 z m 0 0"
|
||||
id="path15" /></clipPath><clipPath
|
||||
id="c"><path
|
||||
d="m 108 94 l -20 20 h -5.714844 v -25.714844 h 25.714844 z m 0 0"
|
||||
id="path18" /></clipPath><linearGradient
|
||||
id="d"
|
||||
gradientTransform="matrix(0,0.178571,0.178571,0,84.428574,42.571447)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="344"
|
||||
x2="340"
|
||||
y1="76"
|
||||
y2="72"><stop
|
||||
offset="0"
|
||||
stop-color="#d5d3cf"
|
||||
id="stop21" /><stop
|
||||
offset="1"
|
||||
stop-color="#ffffff"
|
||||
id="stop23" /></linearGradient><linearGradient
|
||||
id="e"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0"
|
||||
stop-color="#fc9a91"
|
||||
id="stop26" /><stop
|
||||
offset="1"
|
||||
stop-color="#cb2b31"
|
||||
id="stop28" /></linearGradient><linearGradient
|
||||
id="f"
|
||||
gradientTransform="matrix(0.6 0.6 0.707107 -0.707107 295.740141 128.474214)"
|
||||
x1="-253.630356"
|
||||
x2="-230.060135"
|
||||
xlink:href="#e"
|
||||
y1="-56.517502"
|
||||
y2="-56.517502" /><linearGradient
|
||||
id="g"
|
||||
gradientTransform="matrix(0.6 0.6 0.707107 -0.707107 294.325928 129.888428)"
|
||||
x1="-253.630356"
|
||||
x2="-230.060135"
|
||||
xlink:href="#e"
|
||||
y1="-56.517502"
|
||||
y2="-56.517502" /><linearGradient
|
||||
id="h"
|
||||
gradientTransform="matrix(0.390307 -0.130102 0.130102 -0.390307 -2.964383 65.147882)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="181.677414"
|
||||
x2="143.24614"
|
||||
y1="-139.479385"
|
||||
y2="-101.048103"><stop
|
||||
offset="0"
|
||||
stop-color="#d0bb8e"
|
||||
id="stop33" /><stop
|
||||
offset="1"
|
||||
stop-color="#ffffff"
|
||||
id="stop35" /></linearGradient><linearGradient
|
||||
id="i"
|
||||
gradientTransform="matrix(0.6 0.6 0.707107 -0.707107 294.32593 129.888425)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="-230.060135"
|
||||
x2="-253.630356"
|
||||
y1="-64.517509"
|
||||
y2="-64.517509"><stop
|
||||
offset="0"
|
||||
stop-color="#c0bfbc"
|
||||
id="stop38" /><stop
|
||||
offset="0.223152"
|
||||
stop-color="#9a9996"
|
||||
id="stop40" /><stop
|
||||
offset="0.743841"
|
||||
stop-color="#d6d5d2"
|
||||
id="stop42" /><stop
|
||||
offset="1"
|
||||
stop-color="#f6f5f4"
|
||||
id="stop44" /></linearGradient><g
|
||||
style="display:none"
|
||||
id="layer2"
|
||||
transform="translate(-224.45584,-111.52517)"><text
|
||||
id="context"
|
||||
y="128.65199"
|
||||
x="-0.050041199"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:5.33333px;line-height:125%;font-family:Cantarell;-inkscape-font-specification:'Cantarell, Normal';text-align:start;writing-mode:lr-tb;text-anchor:start;display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.332649;enable-background:new"
|
||||
xml:space="preserve"><tspan
|
||||
style="font-size:5.33333px;stroke-width:0.332649"
|
||||
y="128.65199"
|
||||
x="-0.050041199"
|
||||
id="tspan2716">apps</tspan></text><text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:5.33333px;line-height:125%;font-family:Cantarell;-inkscape-font-specification:'Cantarell, Bold';text-align:start;writing-mode:lr-tb;text-anchor:start;display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.332649;enable-background:new"
|
||||
x="-0.24669456"
|
||||
y="137.23398"
|
||||
id="text3021"><tspan
|
||||
style="font-size:5.33333px;stroke-width:0.332649"
|
||||
id="tspan3023"
|
||||
x="-0.24669456"
|
||||
y="137.23398">org.gnome.Notes</tspan></text><g
|
||||
id="g12027"
|
||||
transform="matrix(7.9911709,0,0,8.0036407,-167.7909,-4846.0776)"
|
||||
style="display:inline;fill:#000000;enable-background:new" /><rect
|
||||
y="172"
|
||||
x="9.2651362e-08"
|
||||
height="128"
|
||||
width="128"
|
||||
id="rect13805"
|
||||
style="display:inline;overflow:visible;visibility:visible;fill:#f0f0f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5;marker:none;enable-background:accumulate" /><g
|
||||
transform="translate(-24,24)"
|
||||
style="fill:none;fill-opacity:0.25098;stroke:#a579b3;stroke-opacity:1"
|
||||
id="g883" /><g
|
||||
transform="translate(-24,24)"
|
||||
style="fill:none;fill-opacity:0.25098;stroke:#a579b3;stroke-opacity:1"
|
||||
id="g900" /><rect
|
||||
style="display:inline;overflow:visible;visibility:visible;fill:#f0f0f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5;marker:none;enable-background:accumulate"
|
||||
id="rect859"
|
||||
width="16"
|
||||
height="16"
|
||||
x="160"
|
||||
y="172" /><text
|
||||
id="text863"
|
||||
y="164"
|
||||
x="0"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4px;line-height:125%;font-family:Cantarell;-inkscape-font-specification:'Cantarell, Bold';text-align:start;writing-mode:lr-tb;text-anchor:start;display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.332649;enable-background:new"
|
||||
xml:space="preserve"><tspan
|
||||
y="164"
|
||||
x="0"
|
||||
id="tspan861"
|
||||
style="font-size:4px;stroke-width:0.332649">Hicolor</tspan></text><text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4px;line-height:125%;font-family:Cantarell;-inkscape-font-specification:'Cantarell, Bold';text-align:start;writing-mode:lr-tb;text-anchor:start;display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.332649;enable-background:new"
|
||||
x="160"
|
||||
y="164"
|
||||
id="text867"><tspan
|
||||
style="font-size:4px;stroke-width:0.332649"
|
||||
id="tspan865"
|
||||
x="160"
|
||||
y="164">Symbolic</tspan></text></g><g
|
||||
style="display:none"
|
||||
id="layer3"
|
||||
transform="translate(-224.45584,-111.52517)"><circle
|
||||
style="display:inline;opacity:0.1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:0.99, 0.99;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
id="circle2892"
|
||||
r="59.504131"
|
||||
cy="236"
|
||||
cx="64.000031" /><rect
|
||||
style="display:inline;opacity:0.1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:0.99, 0.99;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
id="rect2894"
|
||||
width="87.009987"
|
||||
height="111.01005"
|
||||
x="20.495007"
|
||||
y="180.49496"
|
||||
rx="8.701004"
|
||||
ry="7.9292889" /><rect
|
||||
style="display:inline;opacity:0.1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:0.99, 0.99;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
id="rect2896"
|
||||
width="103.00952"
|
||||
height="103.00952"
|
||||
x="12.495266"
|
||||
y="184.49524"
|
||||
rx="7.9238095"
|
||||
ry="7.9238095" /><rect
|
||||
style="display:inline;opacity:0.1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:0.99, 0.99;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
id="rect2898"
|
||||
width="111.01004"
|
||||
height="87.010048"
|
||||
x="8.4950066"
|
||||
y="200.49496"
|
||||
rx="7.9292889"
|
||||
ry="8.701005" /><path
|
||||
style="display:inline;fill:none;stroke:#62a0ea;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
|
||||
d="M 2.6203015e-5,288.99999 H 128.00003"
|
||||
id="path2900" /></g><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="21.753124"
|
||||
x="11.999999"
|
||||
height="94.366623"
|
||||
width="104"
|
||||
id="rect884"
|
||||
style="fill:url(#linearGradient973);fill-opacity:1;stroke:none;stroke-width:6;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" /><rect
|
||||
ry="8"
|
||||
rx="8"
|
||||
y="21.148834"
|
||||
x="11.999999"
|
||||
height="82.970901"
|
||||
width="104"
|
||||
id="rect886"
|
||||
style="display:inline;fill:#f6f5f4;fill-opacity:1;stroke:none;stroke-width:6;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /><path
|
||||
id="rect886-3"
|
||||
style="display:inline;fill:#086dd6;fill-opacity:1;stroke:none;stroke-width:5.97638;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
d="m 19.999994,12.11975 c -4.431985,0 -7.999995,3.539965 -7.999995,7.93713 V 30.11975 H 116 V 20.05688 c 0,-4.397165 -3.56802,-7.93713 -8,-7.93713 z" /><path
|
||||
id="path888"
|
||||
d="M 116,80.119749 92,104.11975 h 16 c 4.432,0 8,-3.568 8,-8.000001 z"
|
||||
style="display:inline;fill:#e8e5e3;fill-opacity:1;stroke:none;stroke-width:6;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /><path
|
||||
id="path890"
|
||||
d="m 100,80.119749 c -4.432,0 -8,3.568 -8,8 v 16.000001 l 24,-24.000001 z"
|
||||
style="display:inline;fill:url(#radialGradient902);fill-opacity:1;stroke:none;stroke-width:6;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /><rect
|
||||
y="44.119751"
|
||||
x="23.999998"
|
||||
height="8"
|
||||
width="79.999947"
|
||||
id="rect892"
|
||||
style="display:inline;fill:#deddda;fill-opacity:1;stroke:none;stroke-width:7.90183;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" /><rect
|
||||
style="display:inline;fill:#deddda;fill-opacity:1;stroke:none;stroke-width:5.72542;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
id="rect894"
|
||||
width="42"
|
||||
height="8"
|
||||
x="23.999998"
|
||||
y="60.119751" /><path
|
||||
id="path1247"
|
||||
d="m 116,100.77405 c -0.0822,4.09061 -3.38071,7.3457 -7.49219,7.3457 h -0.5 v 1 h 0.5 c 3.24611,0 6.0601,-1.81286 7.49219,-4.48242 z m -42,7.3457 34,1 v -1 z"
|
||||
style="display:inline;fill:#000000;fill-opacity:0.0590551;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" /><path
|
||||
id="path1245"
|
||||
d="m 116,104.77405 c -0.0822,4.09061 -3.38071,7.3457 -7.49219,7.3457 h -0.5 v 1 h 0.5 c 3.22393,0 6.02184,-1.7881 7.46289,-4.42773 C 115.9841,108.50227 116,108.313 116,108.11975 Z m -42,7.3457 34,1 v -1 z"
|
||||
style="display:inline;fill:#000000;fill-opacity:0.0431373;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-607.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8005"
|
||||
style="display:inline;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-657.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8015"
|
||||
style="display:inline;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-647.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8025"
|
||||
style="display:inline;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-637.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8035"
|
||||
style="display:inline;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-627.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8045"
|
||||
style="display:inline;enable-background:new" /><g
|
||||
transform="matrix(0.25,0,0,0.25,-617.4466,190.40528)"
|
||||
clip-path="url(#clipPath2213)"
|
||||
id="g8055"
|
||||
style="display:inline;enable-background:new" /></svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
19
src/App.jsx
Normal file
19
src/App.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import NavBar from '@/components/NavBar.jsx';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import Home from '@/pages/Home.jsx'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<NavBar />
|
||||
<div className="fill d-flex flex-column">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/:paste_key" element={<Home />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
14
src/api/axiosInstance.js
Normal file
14
src/api/axiosInstance.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import axios from "axios";
|
||||
|
||||
const createAxiosInstance = (baseURL, token) => {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
});
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
export default createAxiosInstance;
|
||||
92
src/components/AnimatedDropdown.jsx
Normal file
92
src/components/AnimatedDropdown.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
||||
import '../css/AnimatedDropdown.css';
|
||||
|
||||
const AnimatedDropdown = ({
|
||||
trigger,
|
||||
icon,
|
||||
variant = "secondary",
|
||||
className = "",
|
||||
buttonStyle = "",
|
||||
show,
|
||||
onToggle,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
children
|
||||
}) => {
|
||||
const isControlled = show !== undefined;
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const actualOpen = isControlled ? show : open;
|
||||
|
||||
const toggle = () => {
|
||||
const newState = !actualOpen;
|
||||
if (!isControlled) setOpen(newState);
|
||||
onToggle?.(newState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target) &&
|
||||
!triggerRef.current?.contains(e.target)
|
||||
) {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isControlled, onToggle]);
|
||||
|
||||
const triggerElement = trigger
|
||||
? (typeof trigger === "function"
|
||||
? trigger({ onClick: toggle, ref: triggerRef })
|
||||
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
|
||||
: (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant={variant}
|
||||
className={`circle-btn ${buttonStyle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`position-relative d-inline-block`}
|
||||
onClick={toggle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{triggerElement}
|
||||
|
||||
<AnimatePresence>
|
||||
{actualOpen && (
|
||||
<_motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={dropdownClasses}
|
||||
>
|
||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
||||
</_motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedDropdown;
|
||||
122
src/components/AnimatedDropend.jsx
Normal file
122
src/components/AnimatedDropend.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
||||
import '@/css/AnimatedDropdown.css';
|
||||
|
||||
const AnimatedDropend = ({
|
||||
trigger,
|
||||
icon,
|
||||
variant = "secondary",
|
||||
className = "",
|
||||
buttonStyle = "",
|
||||
show,
|
||||
onToggle,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
children
|
||||
}) => {
|
||||
const isControlled = show !== undefined;
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const actualOpen = isControlled ? show : open;
|
||||
|
||||
const toggle = (forceValue) => {
|
||||
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
|
||||
if (!isControlled) setOpen(newState);
|
||||
onToggle?.(newState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target) &&
|
||||
!triggerRef.current?.contains(e.target)
|
||||
) {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isControlled, onToggle]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isControlled) setOpen(true);
|
||||
onToggle?.(true);
|
||||
onMouseEnter?.();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
onMouseLeave?.();
|
||||
};
|
||||
|
||||
const triggerElement = trigger
|
||||
? (typeof trigger === "function"
|
||||
? trigger({
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
ref: triggerRef
|
||||
})
|
||||
: cloneElement(trigger, {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
ref: triggerRef
|
||||
}))
|
||||
: (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant={variant}
|
||||
className={`circle-btn ${buttonStyle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="position-relative d-inline-block dropend"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{triggerElement}
|
||||
|
||||
<AnimatePresence>
|
||||
{actualOpen && (
|
||||
<_motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={dropdownClasses}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '100%',
|
||||
zIndex: 1000,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
||||
</_motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedDropend;
|
||||
8
src/components/Auth/IfAuthenticated.jsx
Normal file
8
src/components/Auth/IfAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
|
||||
const IfAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "authenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfAuthenticated;
|
||||
8
src/components/Auth/IfNotAuthenticated.jsx
Normal file
8
src/components/Auth/IfNotAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
|
||||
const IfNotAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "unauthenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfNotAuthenticated;
|
||||
13
src/components/Auth/IfRole.jsx
Normal file
13
src/components/Auth/IfRole.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
|
||||
const IfRole = ({ roles, children }) => {
|
||||
const { user, authStatus } = useAuth();
|
||||
|
||||
if (authStatus !== "authenticated") return null;
|
||||
|
||||
const userRole = user?.role;
|
||||
|
||||
return roles.includes(userRole) ? children : null;
|
||||
};
|
||||
|
||||
export default IfRole;
|
||||
120
src/components/Auth/LoginForm.jsx
Normal file
120
src/components/Auth/LoginForm.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
|
||||
import PasswordInput from '@/components/Auth/PasswordInput.jsx';
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/AuthContext.jsx";
|
||||
|
||||
import CustomContainer from '@/components/CustomContainer.jsx';
|
||||
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
||||
|
||||
import '@/css/LoginForm.css';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, error } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
emailOrUserName: "",
|
||||
password: "",
|
||||
keepLoggedIn: false
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
|
||||
|
||||
const loginBody = {
|
||||
password: formState.password,
|
||||
keepLoggedIn: Boolean(formState.keepLoggedIn),
|
||||
};
|
||||
|
||||
if (isEmail) {
|
||||
loginBody.email = formState.emailOrUserName;
|
||||
} else {
|
||||
loginBody.userName = formState.emailOrUserName;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(loginBody);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
console.error("Error de login:", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomContainer>
|
||||
<ContentWrapper>
|
||||
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
||||
<h1 className="text-center">Inicio de sesión</h1>
|
||||
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<FloatingLabel
|
||||
controlId="floatingUsuario"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
Usuario o Email
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder=""
|
||||
name="emailOrUserName"
|
||||
value={formState.emailOrUserName}
|
||||
onChange={handleChange}
|
||||
className="rounded-4"
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<PasswordInput
|
||||
value={formState.password}
|
||||
onChange={handleChange}
|
||||
name="password"
|
||||
/>
|
||||
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
name="keepLoggedIn"
|
||||
label="Mantener sesión iniciada"
|
||||
className="text-secondary"
|
||||
value={formState.keepLoggedIn}
|
||||
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
||||
/>
|
||||
{/*<Link disabled to="#" className="muted">
|
||||
Olvidé mi contraseña
|
||||
</Link>*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" className="text-center py-2 mb-0">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</CustomContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default LoginForm;
|
||||
48
src/components/Auth/PasswordInput.jsx
Normal file
48
src/components/Auth/PasswordInput.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, FloatingLabel, Button } from 'react-bootstrap';
|
||||
import '@/css/PasswordInput.css';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const toggleShow = () => setShow(prev => !prev);
|
||||
|
||||
return (
|
||||
<div className="position-relative w-100">
|
||||
<FloatingLabel
|
||||
controlId="passwordInput"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faKey} className="me-2" />
|
||||
Contraseña
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
type={show ? "text" : "password"}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder=""
|
||||
onChange={onChange}
|
||||
className="rounded-4 pe-5"
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
|
||||
onClick={toggleShow}
|
||||
aria-label="Mostrar contraseña"
|
||||
tabIndex={-1}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
||||
78
src/components/Auth/PasswordModal.jsx
Normal file
78
src/components/Auth/PasswordModal.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, Button, Form } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useState } from 'react';
|
||||
import PasswordInput from '@/components/Auth/PasswordInput';
|
||||
import { renderErrorAlert } from '@/util/alertHelpers';
|
||||
import '@/css/PasswordModal.css';
|
||||
|
||||
const PasswordModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSubmit,
|
||||
error = null,
|
||||
loading = false
|
||||
}) => {
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (password.trim() === "") return;
|
||||
onSubmit(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} centered>
|
||||
<Modal.Header
|
||||
style={{ backgroundColor: "var(--modal-bg)" }}
|
||||
>
|
||||
<Modal.Title>
|
||||
<FontAwesomeIcon icon={faLock} className="me-2" />
|
||||
Paste protegida
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body style={{ backgroundColor: "var(--modal-body-bg)" }}>
|
||||
<p className="mb-3">
|
||||
Esta paste está protegida con contraseña. Introduce la clave para continuar.
|
||||
</p>
|
||||
|
||||
{renderErrorAlert(error)}
|
||||
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} className='dialog-btn'>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className='dialog-btn'
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || password.trim() === ""}
|
||||
style={{
|
||||
backgroundColor: "var(--btn-bg)",
|
||||
borderColor: "var(--btn-bg)",
|
||||
color: "var(--btn-text)"
|
||||
}}
|
||||
>
|
||||
{loading ? "Verificando..." : "Acceder"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordModal.propTypes = {
|
||||
show: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
loading: PropTypes.bool
|
||||
};
|
||||
|
||||
export default PasswordModal;
|
||||
18
src/components/Auth/ProtectedRoute.jsx
Normal file
18
src/components/Auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const ProtectedRoute = ({ minimumRoles, children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
|
||||
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
|
||||
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
|
||||
if (authStatus === "authenticated" && minimumRoles) {
|
||||
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
|
||||
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
15
src/components/ContentWrapper.jsx
Normal file
15
src/components/ContentWrapper.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ContentWrapper = ({ children }) => {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ContentWrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default ContentWrapper;
|
||||
47
src/components/CustomCarousel.jsx
Normal file
47
src/components/CustomCarousel.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Slider from 'react-slick';
|
||||
import '@/css/CustomCarousel.css';
|
||||
|
||||
const CustomCarousel = ({ images }) => {
|
||||
const settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768, // móviles
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Slider {...settings}>
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className='carousel-img-wrapper'>
|
||||
<img
|
||||
src={src}
|
||||
alt={`slide-${index}`}
|
||||
className="carousel-img"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCarousel;
|
||||
15
src/components/CustomContainer.jsx
Normal file
15
src/components/CustomContainer.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const CustomContainer = ({ children }) => {
|
||||
return (
|
||||
<main className="px-4 py-5">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
CustomContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default CustomContainer;
|
||||
26
src/components/CustomModal.jsx
Normal file
26
src/components/CustomModal.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
|
||||
const CustomModal = ({ show, onClose, title, children }) => {
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} size="xl" centered>
|
||||
<Modal.Header className='justify-content-between rounded-top-4'>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Button variant='transparent' onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||
</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="rounded-bottom-4 p-0"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomModal;
|
||||
56
src/components/Footer.jsx
Normal file
56
src/components/Footer.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
||||
import '@/css/Footer.css';
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
const Footer = () => {
|
||||
const [heart, setHeart] = useState('💜');
|
||||
|
||||
useEffect(() => {
|
||||
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
|
||||
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setHeart(randomHeart());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
|
||||
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
|
||||
<div className="footer-column">
|
||||
<h4 className="footer-title">Contacto</h4>
|
||||
<div className="contact-info p-4">
|
||||
<a
|
||||
href="https://github.com/Gallardo7761"
|
||||
target="_blank"
|
||||
className='text-break d-block'
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} className="fa-icon me-2 " />
|
||||
Gallardo7761
|
||||
</a>
|
||||
<a href="mailto:jose@miarma.net" className="text-break d-block">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
|
||||
jose@miarma.net
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom w-100 py-5 text-center">
|
||||
<h6 id="devd" className='m-0'>
|
||||
Hecho con <span className="heart-anim">{heart}</span> por{' '}
|
||||
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
|
||||
Gallardo7761
|
||||
</a>
|
||||
</h6>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
19
src/components/Header.jsx
Normal file
19
src/components/Header.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import '@/css/Header.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Header = () => {
|
||||
|
||||
return (
|
||||
<header className={`text-center bg-img`}>
|
||||
<div className="m-0 p-5 mask">
|
||||
<div className="d-flex flex-column justify-content-center align-items-center h-100">
|
||||
<Link to='/' className='text-decoration-none'>
|
||||
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
10
src/components/LoadingIcon.jsx
Normal file
10
src/components/LoadingIcon.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
const LoadingIcon = () => {
|
||||
return (
|
||||
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIcon;
|
||||
119
src/components/NavBar.jsx
Normal file
119
src/components/NavBar.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import '@/css/NavBar.css';
|
||||
import ThemeButton from '@/components/ThemeButton.jsx';
|
||||
import { Navbar, Nav, Container } from 'react-bootstrap';
|
||||
import SearchToolbar from './SearchToolbar';
|
||||
import { useSearch } from "@/context/SearchContext";
|
||||
import NotificationModal from './NotificationModal';
|
||||
|
||||
const NavBar = () => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
|
||||
const [isXs, setIsXs] = useState(window.innerWidth < 576);
|
||||
const { searchTerm, setSearchTerm } = useSearch();
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
|
||||
setIsXs(window.innerWidth < 576);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 992) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
expand="lg"
|
||||
sticky="top"
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
className='shadow-none custom-border-bottom'
|
||||
>
|
||||
<Container fluid>
|
||||
{/* brand */}
|
||||
<Nav.Item
|
||||
title="mpaste"
|
||||
className={`navbar-brand`}
|
||||
>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img src='/images/favicon.svg' width={44} height={44} />
|
||||
<h3 className='m-0 p-0'>mpaste</h3>
|
||||
</div>
|
||||
</Nav.Item>
|
||||
|
||||
{/* ThemeButton SIEMPRE fijo */}
|
||||
<div className="order-lg-2 ms-auto me-2">
|
||||
<ThemeButton onlyIcon={isXs} />
|
||||
</div>
|
||||
|
||||
{/* burger */}
|
||||
<Navbar.Toggle
|
||||
aria-controls="main-navbar"
|
||||
className="custom-toggler border-0 order-lg-3"
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M4 7h22M4 15h22M4 23h22"
|
||||
stroke="var(--navbar-link-color)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
</svg>
|
||||
</Navbar.Toggle>
|
||||
|
||||
{/* links y search que colapsan */}
|
||||
<Navbar.Collapse id="main-navbar" className="order-lg-1">
|
||||
<Nav
|
||||
className={`me-auto gap-3 w-100 ${expanded ? "flex-column align-items-start mt-3 mb-2" : "d-flex align-items-center"}`}
|
||||
>
|
||||
<Nav.Link as={Link} to="/" onClick={() => setExpanded(false)}>inicio</Nav.Link>
|
||||
<Nav.Link as={Link} onClick={() => { setShowContactModal(true); setExpanded(false); }}>sugerencias</Nav.Link>
|
||||
<div className="w-50">
|
||||
<SearchToolbar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
</div>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
{/* Contact Modal */}
|
||||
<NotificationModal
|
||||
show={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
title="Contacto"
|
||||
message={
|
||||
<span>
|
||||
Si tienes alguna pregunta o sugerencia, me puedes escribir a mi correo: <br />
|
||||
<strong>jose [arroba] miarma.net</strong>
|
||||
</span>
|
||||
}
|
||||
variant=""
|
||||
buttons={[
|
||||
{ label: "Cerrar", variant: "secondary", onClick: () => setShowContactModal(false) }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
70
src/components/NotificationModal.jsx
Normal file
70
src/components/NotificationModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, Button } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faCircleCheck,
|
||||
faCircleXmark,
|
||||
faCircleExclamation,
|
||||
faCircleInfo
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import '@/css/NotificationModal.css';
|
||||
|
||||
const iconMap = {
|
||||
success: faCircleCheck,
|
||||
danger: faCircleXmark,
|
||||
warning: faCircleExclamation,
|
||||
info: faCircleInfo
|
||||
};
|
||||
|
||||
const NotificationModal = ({
|
||||
show,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
variant = "info",
|
||||
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
|
||||
}) => {
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} centered>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
|
||||
{title}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<p className="mb-0">{message}</p>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
{buttons.map((btn, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={btn.variant || "primary"}
|
||||
onClick={btn.onClick || onClose}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
NotificationModal.propTypes = {
|
||||
show: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
|
||||
buttons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
variant: PropTypes.string,
|
||||
onClick: PropTypes.func
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
export default NotificationModal;
|
||||
59
src/components/Pastes/CodeEditor.jsx
Normal file
59
src/components/Pastes/CodeEditor.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { loader } from '@monaco-editor/react';
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'es' },
|
||||
},
|
||||
});
|
||||
|
||||
const CodeEditor = ({ className = "", syntax, readOnly, onChange, value }) => {
|
||||
const { theme } = useTheme();
|
||||
const editorRef = useRef(null);
|
||||
|
||||
const onMount = (editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`code-editor ${className}`}>
|
||||
<Editor
|
||||
language={syntax || "plaintext"}
|
||||
value={value || ""}
|
||||
theme={theme === "dark" ? "vs-dark" : "vs-light"}
|
||||
onChange={(value) => onChange?.(value)}
|
||||
onMount={onMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
fontFamily: 'Fira Code',
|
||||
fontLigatures: true,
|
||||
fontSize: 18,
|
||||
lineHeight: 1.5,
|
||||
scrollbar: { verticalScrollbarSize: 0 },
|
||||
wordWrap: "on",
|
||||
formatOnPaste: true,
|
||||
suggest: {
|
||||
showFields: true,
|
||||
showFunctions: true,
|
||||
},
|
||||
readOnly: readOnly || false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CodeEditor.propTypes = {
|
||||
className: PropTypes.string,
|
||||
syntax: PropTypes.string,
|
||||
readOnly: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CodeEditor;
|
||||
235
src/components/Pastes/PastePanel.jsx
Normal file
235
src/components/Pastes/PastePanel.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Form, Button, Row, Col, FloatingLabel, Alert } from "react-bootstrap";
|
||||
import '@/css/PastePanel.css';
|
||||
import PasswordInput from "@/components/Auth/PasswordInput";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCode, faHeader } from "@fortawesome/free-solid-svg-icons";
|
||||
import CodeEditor from "./CodeEditor";
|
||||
import PublicPasteItem from "./PublicPasteItem";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useDataContext } from "@/hooks/useDataContext";
|
||||
import PasswordModal from "@/components/Auth/PasswordModal.jsx";
|
||||
|
||||
const PastePanel = ({ onSubmit, publicPastes }) => {
|
||||
const { paste_key } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { getData } = useDataContext();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [syntax, setSyntax] = useState("");
|
||||
const [burnAfter, setBurnAfter] = useState(false);
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedPaste, setSelectedPaste] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const paste = {
|
||||
title,
|
||||
content,
|
||||
syntax,
|
||||
burn_after: burnAfter,
|
||||
is_private: isPrivate,
|
||||
password: password || null,
|
||||
};
|
||||
if (onSubmit) onSubmit(paste);
|
||||
};
|
||||
|
||||
const handleSelectPaste = async (key) => {
|
||||
navigate(`/${key}`);
|
||||
};
|
||||
|
||||
const fetchPaste = async (key, pwd = "") => {
|
||||
const url = `https://api.miarma.net/mpaste/v1/pastes/${key}`;
|
||||
const { data, error } = await getData(url, {}, {
|
||||
'X-Paste-Password': pwd
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error?.status === 401) {
|
||||
setShowPasswordModal(true);
|
||||
return;
|
||||
} else {
|
||||
setError(error);
|
||||
setSelectedPaste(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSelectedPaste(data);
|
||||
setTitle(data.title);
|
||||
setContent(data.content);
|
||||
setSyntax(data.syntax || "plaintext");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (paste_key) fetchPaste(paste_key);
|
||||
}, [paste_key]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="paste-panel border-0 flex-fill d-flex flex-column min-h-0 p-3">
|
||||
{error &&
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
<strong>
|
||||
<span className="text-danger">{
|
||||
error.status == 404 ? "404: Paste no encontrada." :
|
||||
"Ha ocurrido un error al cargar la paste."
|
||||
}</span>
|
||||
</strong>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<Form onSubmit={handleSubmit} className="flex-fill d-flex flex-column min-h-0">
|
||||
<Row className="g-3 flex-fill min-h-0">
|
||||
<Col xs={12} lg={2} className="order-last order-lg-first d-flex flex-column flex-fill min-h-0 overflow-hidden">
|
||||
<div className="public-pastes d-flex flex-column flex-fill overflow-hidden">
|
||||
<h4>pastes públicas</h4>
|
||||
<hr />
|
||||
<div className="overflow-auto flex-fill" style={{ scrollbarWidth: 'none' }}>
|
||||
{publicPastes && publicPastes.length > 0 ? (
|
||||
publicPastes.map((paste) => (
|
||||
<PublicPasteItem
|
||||
key={paste.paste_key}
|
||||
paste={paste}
|
||||
onSelect={handleSelectPaste}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>No hay pastes públicas disponibles.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} lg={7} className="d-flex flex-column flex-fill min-h-0 overflow-hidden">
|
||||
<CodeEditor
|
||||
className="flex-fill custom-border rounded-4 overflow-hidden pt-4 pe-4"
|
||||
syntax={syntax}
|
||||
readOnly={!!selectedPaste}
|
||||
onChange={selectedPaste ? undefined : setContent}
|
||||
value={content}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} lg={3} className="d-flex flex-column flex-fill min-h-0 overflow-hidden">
|
||||
<div className="d-flex flex-column flex-fill gap-3 overflow-auto">
|
||||
<FloatingLabel
|
||||
controlId="titleInput"
|
||||
label={
|
||||
<span className={selectedPaste ? "text-white" : ""}>
|
||||
<FontAwesomeIcon icon={faHeader} className="me-2" />
|
||||
Título
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
disabled={!!selectedPaste}
|
||||
type="text"
|
||||
placeholder="Título de la paste"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<FloatingLabel
|
||||
controlId="syntaxSelect"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCode} className="me-2" />
|
||||
Sintaxis
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Select
|
||||
disabled={!!selectedPaste}
|
||||
value={syntax}
|
||||
onChange={(e) => setSyntax(e.target.value)}
|
||||
>
|
||||
<option value="">Sin resaltado</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
<option value="python">Python</option>
|
||||
<option value="java">Java</option>
|
||||
<option value="c">C</option>
|
||||
<option value="cpp">C++</option>
|
||||
<option value="bash">Bash</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="css">CSS</option>
|
||||
<option value="sql">SQL</option>
|
||||
<option value="julia">Julia</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="yaml">YAML</option>
|
||||
<option value="php">PHP</option>
|
||||
<option value="ruby">Ruby</option>
|
||||
<option value="go">Go</option>
|
||||
<option value="rust">Rust</option>
|
||||
<option value="typescript">TypeScript</option>
|
||||
<option value="kotlin">Kotlin</option>
|
||||
<option value="swift">Swift</option>
|
||||
<option value="csharp">C#</option>
|
||||
<option value="perl">Perl</option>
|
||||
<option value="r">R</option>
|
||||
<option value="dart">Dart</option>
|
||||
<option value="lua">Lua</option>
|
||||
<option value="haskell">Haskell</option>
|
||||
<option value="scala">Scala</option>
|
||||
<option value="objectivec">Objective-C</option>
|
||||
</Form.Select>
|
||||
</FloatingLabel>
|
||||
|
||||
<Form.Check
|
||||
type="switch"
|
||||
disabled={!!selectedPaste}
|
||||
id="burnAfter"
|
||||
label="volátil"
|
||||
checked={burnAfter}
|
||||
onChange={(e) => setBurnAfter(e.target.checked)}
|
||||
className="ms-1 d-flex gap-2 align-items-center"
|
||||
/>
|
||||
|
||||
<Form.Check
|
||||
type="switch"
|
||||
disabled={!!selectedPaste}
|
||||
id="isPrivate"
|
||||
label="privado"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
className="ms-1 d-flex gap-2 align-items-center"
|
||||
/>
|
||||
|
||||
{isPrivate && (
|
||||
<PasswordInput onChange={(e) => setPassword(e.target.value)} />
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!!selectedPaste}
|
||||
>
|
||||
Crear paste
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
<PasswordModal
|
||||
show={showPasswordModal}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
onSubmit={(pwd) => {
|
||||
setShowPasswordModal(false);
|
||||
fetchPaste(paste_key, pwd); // reintentas con la pass
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default PastePanel;
|
||||
29
src/components/Pastes/PublicPasteItem.jsx
Normal file
29
src/components/Pastes/PublicPasteItem.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const trimContent = (text, maxLength = 80) => {
|
||||
if (!text) return "";
|
||||
return text.length <= maxLength ? text : text.slice(0, maxLength) + "...";
|
||||
};
|
||||
|
||||
const PublicPasteItem = ({ paste, onSelect }) => {
|
||||
return (
|
||||
<div className="public-paste-item p-2 mb-2 rounded custom-border" style={{ cursor: "pointer" }} onClick={() => onSelect(paste.paste_key)}>
|
||||
<h5 className="m-0">{paste.title}</h5>
|
||||
<p className="m-0 text-truncate">{trimContent(paste.content, 100)}</p>
|
||||
<small className="custom-text-muted">
|
||||
{new Date(paste.created_at).toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PublicPasteItem.propTypes = {
|
||||
paste: PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
content: PropTypes.string.isRequired,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default PublicPasteItem;
|
||||
15
src/components/SearchToolbar.jsx
Normal file
15
src/components/SearchToolbar.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import '@/css/SearchToolbar.css';
|
||||
|
||||
const SearchToolbar = ({ searchTerm, onSearchChange }) => (
|
||||
<div className="search-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SearchToolbar;
|
||||
18
src/components/ThemeButton.jsx
Normal file
18
src/components/ThemeButton.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useTheme } from "@/hooks/useTheme.js";
|
||||
import "@/css/ThemeButton.css";
|
||||
|
||||
export default function ThemeButton({ className, onlyIcon}) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
|
||||
{
|
||||
onlyIcon ? (
|
||||
theme === "dark" ? ("🌞") : ("🌙")
|
||||
) : (
|
||||
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
|
||||
)
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
98
src/context/AuthContext.jsx
Normal file
98
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, createContext } from "react";
|
||||
import createAxiosInstance from "@/api/axiosInstance";
|
||||
import { useConfig } from "@/hooks/useConfig";
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const axios = createAxiosInstance();
|
||||
const { config } = useConfig();
|
||||
|
||||
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
|
||||
const [token, setToken] = useState(() => localStorage.getItem("token"));
|
||||
const [authStatus, setAuthStatus] = useState("checking");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
if (!token) {
|
||||
setAuthStatus("unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const BASE_URL = config.apiConfig.baseUrl;
|
||||
const VALIDATE_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.validateToken}`;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await axios.get(VALIDATE_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setAuthStatus("authenticated");
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error validando token:", err);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [token, config]);
|
||||
|
||||
const login = async (formData) => {
|
||||
setError(null);
|
||||
const BASE_URL = config.apiConfig.baseUrl;
|
||||
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
|
||||
|
||||
try {
|
||||
const res = await axios.post(LOGIN_URL, formData);
|
||||
const { token, member, tokenTime } = res.data.data;
|
||||
|
||||
localStorage.setItem("token", token);
|
||||
localStorage.setItem("user", JSON.stringify(member));
|
||||
localStorage.setItem("tokenTime", tokenTime);
|
||||
|
||||
setToken(token);
|
||||
setUser(member);
|
||||
setAuthStatus("authenticated");
|
||||
} catch (err) {
|
||||
console.error("Error al iniciar sesión:", err);
|
||||
|
||||
let message = "Ha ocurrido un error inesperado.";
|
||||
|
||||
if (err.response) {
|
||||
const { status, data } = err.response;
|
||||
|
||||
if (status === 400) {
|
||||
message = "Usuario o contraseña incorrectos.";
|
||||
} else if (status === 403) {
|
||||
message = "Tu cuenta está inactiva o ha sido suspendida.";
|
||||
} else if (status === 404) {
|
||||
message = "Usuario no encontrado.";
|
||||
} else if (data?.message) {
|
||||
message = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setAuthStatus("unauthenticated");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
41
src/context/ConfigContext.jsx
Normal file
41
src/context/ConfigContext.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createContext, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ConfigContext = createContext();
|
||||
|
||||
export const ConfigProvider = ({ children }) => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configLoading, setLoading] = useState(true);
|
||||
const [configError, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = import.meta.env.MODE === 'production'
|
||||
? await fetch("/config/settings.prod.json")
|
||||
: await fetch("/config/settings.dev.json");
|
||||
if (!response.ok) throw new Error("Error al cargar settings.*.json");
|
||||
const json = await response.json();
|
||||
setConfig(json);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{ config, configLoading, configError }}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export {ConfigContext};
|
||||
23
src/context/DataContext.jsx
Normal file
23
src/context/DataContext.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useData } from "../hooks/useData";
|
||||
|
||||
export const DataContext = createContext();
|
||||
|
||||
export const DataProvider = ({ config, children }) => {
|
||||
const data = useData(config);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={data}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
DataProvider.propTypes = {
|
||||
config: PropTypes.shape({
|
||||
baseUrl: PropTypes.string.isRequired,
|
||||
params: PropTypes.object,
|
||||
}).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
15
src/context/SearchContext.jsx
Normal file
15
src/context/SearchContext.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useSearchBar } from "@/hooks/useSearchBar";
|
||||
|
||||
const SearchContext = createContext();
|
||||
|
||||
export const SearchProvider = ({ children }) => {
|
||||
const search = useSearchBar();
|
||||
return (
|
||||
<SearchContext.Provider value={search}>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSearch = () => useContext(SearchContext);
|
||||
31
src/context/ThemeContext.jsx
Normal file
31
src/context/ThemeContext.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
export const ThemeContext = createContext();
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
return (
|
||||
localStorage.getItem("theme") ||
|
||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
document.body.classList.remove("light", "dark");
|
||||
document.body.classList.add(theme);
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
28
src/css/AnimatedDropdown.css
Normal file
28
src/css/AnimatedDropdown.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.dropdown-menu .dropdown-divider {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--bg-color) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
box-shadow: 0 5px 10px var(--shadow-color);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--navbar-dropdown-item-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--secondary-color) !important;
|
||||
}
|
||||
|
||||
.disabled.text-muted {
|
||||
color: var(--muted-color) !important;
|
||||
}
|
||||
51
src/css/Building.css
Normal file
51
src/css/Building.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* ================================
|
||||
BUILDING COMPONENT - VISUAL ONLY
|
||||
================================== */
|
||||
|
||||
.building-container {
|
||||
font-family: 'Product Sans', sans-serif;
|
||||
color: var(--fg-color);
|
||||
animation: fadeInScale 0.5s ease;
|
||||
}
|
||||
|
||||
.building-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.building-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.building-subtitle {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0.5rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Animaciones */
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
11
src/css/CustomCarousel.css
Normal file
11
src/css/CustomCarousel.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.carousel-img-wrapper {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.carousel-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
max-height: 60vh;
|
||||
object-fit: cover;
|
||||
}
|
||||
136
src/css/Footer.css
Normal file
136
src/css/Footer.css
Normal file
@@ -0,0 +1,136 @@
|
||||
.footer {
|
||||
background-color: var(--navbar-bg);
|
||||
color: var(--fg-color);
|
||||
border-top: 2px solid var(--border-color);
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 -2px 8px var(--shadow-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.footer-title,
|
||||
.footer h6#devd {
|
||||
font-family: "Product Sans";
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.footer-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--bg-hover-color);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.footer-columns {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-column {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.footer-column h5 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.footer-column ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-column ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-column ul li a {
|
||||
color: var(--fg-color);
|
||||
text-decoration: none;
|
||||
|
||||
}
|
||||
|
||||
.footer-column ul li a:hover {
|
||||
color: var(--primary-color);
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.footer-bottom a {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--fg-color);
|
||||
|
||||
}
|
||||
|
||||
.footer-bottom a:hover {
|
||||
text-shadow: 0 0 5px currentColor;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--contact-info-bg);
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 4px 10px var(--shadow-color);
|
||||
|
||||
}
|
||||
|
||||
.contact-info a {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.contact-info a:hover {
|
||||
transform: translateX(8px);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.contact-info .fa-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.heart-anim {
|
||||
display: inline-block;
|
||||
animation: heartbeat 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes heartbeat {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
43
src/css/Header.css
Normal file
43
src/css/Header.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* ================================
|
||||
HEADER - ESTILO BASE
|
||||
================================== */
|
||||
|
||||
.bg-img {
|
||||
background-image: url('/images/bg.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/*.mask {
|
||||
background-color: var(--header-mask-color);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
|
||||
}
|
||||
*/
|
||||
|
||||
.header-title {
|
||||
font-family: 'Product Sans';
|
||||
font-size: 3em;
|
||||
font-weight: bolder;
|
||||
color: var(--text-color);
|
||||
|
||||
}
|
||||
|
||||
.shadowed {
|
||||
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
RESPONSIVE HEADER TITLE
|
||||
================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-title {
|
||||
font-size: 2em;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
0
src/css/Home.css
Normal file
0
src/css/Home.css
Normal file
53
src/css/LoginForm.css
Normal file
53
src/css/LoginForm.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* ================================
|
||||
LOGIN - CARD CONTAINER (VISUAL)
|
||||
================================== */
|
||||
|
||||
.login-card {
|
||||
background-color: var(--login-bg) !important;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 10px var(--shadow-color);
|
||||
|
||||
}
|
||||
|
||||
/* ================================
|
||||
INPUTS VISUALES
|
||||
================================== */
|
||||
|
||||
input.form-control {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--input-border);
|
||||
|
||||
}
|
||||
|
||||
/* ================================
|
||||
LABELS PERSONALIZADAS
|
||||
================================== */
|
||||
|
||||
.form-floating>label {
|
||||
font-family: 'Product Sans';
|
||||
font-size: 1.1em;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
.form-floating>label::after {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
BOTÓN VISUAL
|
||||
================================== */
|
||||
|
||||
.login-button {
|
||||
font-family: 'Product Sans' !important;
|
||||
font-size: 1.3em !important;
|
||||
font-weight: bold !important;
|
||||
background-color: var(--login-btn-bg) !important;
|
||||
color: var(--login-btn-text) !important;
|
||||
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--login-btn-hover) !important;
|
||||
color: var(--login-btn-text-hover) !important;
|
||||
}
|
||||
65
src/css/NavBar.css
Normal file
65
src/css/NavBar.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* ================================
|
||||
NAVBAR - VISUAL + THEMING ONLY
|
||||
================================== */
|
||||
|
||||
.navbar {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
box-shadow: var(--navbar-shadow);
|
||||
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--navbar-brand-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: var(--navbar-brand-hover) !important;
|
||||
}
|
||||
|
||||
a.nav-link,
|
||||
.nav-item > a.nav-link,
|
||||
.dropdown-item {
|
||||
font-family: "Product Sans";
|
||||
font-size: larger;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
color: var(--navbar-link-color) !important;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link:focus {
|
||||
background-color: var(--navbar-link-hover-bg) !important;
|
||||
color: var(--navbar-link-hover-color) !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid var(--navbar-divider-color);
|
||||
|
||||
}
|
||||
|
||||
.navbar-toggler:focus,
|
||||
.navbar-toggler:active,
|
||||
.navbar-toggler-icon:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
ANIMACIONES
|
||||
================================== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
70
src/css/NotificationModal.css
Normal file
70
src/css/NotificationModal.css
Normal file
@@ -0,0 +1,70 @@
|
||||
.modal-header {
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
align-items: center !important;
|
||||
gap: 0.75rem !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 1rem 1rem 0 0 !important;
|
||||
background-color: var(--modal-bg) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: transparent !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
button.btn-close {
|
||||
color: var(--modal-close-color) !important;
|
||||
filter: brightness(0.8) !important;
|
||||
transition: filter 0.2s ease !important;
|
||||
}
|
||||
button.btn-close:hover {
|
||||
filter: brightness(1) !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem !important;
|
||||
font-size: 1.05rem !important;
|
||||
color: var(--text-color) !important;
|
||||
background-color: var(--modal-bg) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
align-items: center !important;
|
||||
gap: 0.75rem !important;
|
||||
padding: 1rem !important;
|
||||
border: none !important;
|
||||
border-radius: 0 0 1rem 1rem !important;
|
||||
background-color: var(--modal-bg) !important;
|
||||
}
|
||||
|
||||
.modal-dialog button.dialog-btn,
|
||||
.modal-footer button {
|
||||
padding: 0.6rem 1.4rem !important;
|
||||
border-radius: 0.75rem !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.modal-footer button.btn-primary {
|
||||
background-color: #086dd6 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.modal-footer button.btn-primary:hover {
|
||||
background-color: #005bb5 !important;
|
||||
}
|
||||
|
||||
.modal-footer button.btn-secondary {
|
||||
background-color: #e0e0e0 !important;
|
||||
color: #222 !important;
|
||||
}
|
||||
.dark .modal-footer button.btn-secondary {
|
||||
background-color: #444 !important;
|
||||
color: #eee !important;
|
||||
}
|
||||
7
src/css/PasswordInput.css
Normal file
7
src/css/PasswordInput.css
Normal file
@@ -0,0 +1,7 @@
|
||||
button.show-button {
|
||||
color: var(--show-btn-color);
|
||||
|
||||
}
|
||||
button.show-button:hover {
|
||||
color: var(--show-btn-hover);
|
||||
}
|
||||
62
src/css/PasswordModal.css
Normal file
62
src/css/PasswordModal.css
Normal file
@@ -0,0 +1,62 @@
|
||||
.modal-header {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 1rem 1rem 0 0 !important;
|
||||
background-color: var(--modal-bg) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
button.btn-close {
|
||||
color: var(--modal-close-color) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
padding: 1rem !important;
|
||||
border: none !important;
|
||||
border-radius: 0 0 1rem 1rem !important;
|
||||
background-color: var(--modal-bg) !important;
|
||||
}
|
||||
|
||||
.modal-dialog button.dialog-btn {
|
||||
padding: 0.6rem 1.4rem !important;
|
||||
border-radius: 0.75rem !important;
|
||||
background-color: #086dd6 !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
color: #fff !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.modal-dialog input {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--input-border, #ddd);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1.1rem !important;
|
||||
background-color: var(--input-bg, rgba(255, 255, 255, 0.7));
|
||||
color: var(--input-text, #222);
|
||||
}
|
||||
|
||||
.dark .modal-dialog input {
|
||||
background-color: var(--input-bg, #222) !important;
|
||||
border: 1px solid var(--input-border, #555) !important;
|
||||
color: var(--input-text, #eee) !important;
|
||||
}
|
||||
|
||||
.modal-dialog .form-floating > label {
|
||||
color: var(--label-color, var(--text-color)) !important;
|
||||
}
|
||||
|
||||
.modal-dialog .form-floating > .form-control:focus ~ label,
|
||||
.modal-dialog .form-floating > .form-control:not(:placeholder-shown) ~ label {
|
||||
color: var(--accent-color, var(--text-color)) !important;
|
||||
}
|
||||
132
src/css/PastePanel.css
Normal file
132
src/css/PastePanel.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/* ================================
|
||||
PANEL GENERAL
|
||||
================================== */
|
||||
.paste-panel {
|
||||
background: var(--bg-color, rgba(255, 255, 255, 0.8));
|
||||
color: var(--card-text, #222);
|
||||
}
|
||||
|
||||
/* ================================
|
||||
INPUTS, TEXTAREA Y SELECTS
|
||||
================================== */
|
||||
.paste-panel input,
|
||||
.paste-panel textarea,
|
||||
.paste-panel select {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--input-border, #ddd);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1.1rem !important;
|
||||
background-color: var(--input-bg, rgba(255, 255, 255, 0.7));
|
||||
color: var(--input-text, #222);
|
||||
}
|
||||
|
||||
/* Floating label background */
|
||||
.paste-panel .form-floating textarea ~ label::after {
|
||||
background-color: var(--input-bg, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
.dark .paste-panel .form-floating textarea ~ label::after {
|
||||
background-color: var(--input-bg, #222);
|
||||
}
|
||||
|
||||
/* ================================
|
||||
BOTÓN PRINCIPAL
|
||||
================================== */
|
||||
.paste-panel button[type="submit"] {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: #086dd6;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.paste-panel button[type="submit"]:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(8, 109, 214, 0.3);
|
||||
background-color: #0a5ed6;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
FORM-FLOATING LABEL
|
||||
================================== */
|
||||
.paste-panel .form-floating > label {
|
||||
color: var(--label-color, #333);
|
||||
}
|
||||
|
||||
/* Evitar azul por defecto en focus */
|
||||
.paste-panel .form-floating > .form-control:focus ~ label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
FORM SWITCH / CHECK
|
||||
================================== */
|
||||
.paste-panel .form-check-label {
|
||||
color: var(--text-color, #222);
|
||||
}
|
||||
|
||||
.paste-panel .form-check-input:checked {
|
||||
background-color: #086dd6;
|
||||
border-color: #086dd6;
|
||||
}
|
||||
|
||||
.paste-panel .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(8, 109, 214, 0.25);
|
||||
}
|
||||
|
||||
/* ================================
|
||||
DARK MODE
|
||||
================================== */
|
||||
.dark .paste-panel {
|
||||
background: var(--bg-color, #222);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: var(--card-text, #eee);
|
||||
}
|
||||
|
||||
.dark .paste-panel input,
|
||||
.dark .paste-panel textarea,
|
||||
.dark .paste-panel select {
|
||||
background-color: var(--input-bg, #222);
|
||||
border: 1px solid var(--input-border, #555);
|
||||
color: var(--input-text, #eee);
|
||||
}
|
||||
|
||||
.dark .paste-panel .form-floating > label {
|
||||
color: var(--label-color, #eee);
|
||||
}
|
||||
|
||||
.dark .paste-panel .form-floating > .form-control:focus ~ label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark .paste-panel .form-check-label {
|
||||
color: var(--text-color, #fff);
|
||||
}
|
||||
|
||||
.dark .paste-panel .form-check-input:checked {
|
||||
background-color: #086dd6;
|
||||
border-color: #086dd6;
|
||||
}
|
||||
|
||||
.dark .paste-panel .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(8, 109, 214, 0.25);
|
||||
}
|
||||
|
||||
.dark .paste-panel button[type="submit"] {
|
||||
background-color: #086dd6;
|
||||
}
|
||||
|
||||
.dark .paste-panel button[type="submit"]:hover {
|
||||
background-color: #0a5ed6;
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
CODE EDITOR
|
||||
================================== */
|
||||
|
||||
.code-editor {
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--code-bg);
|
||||
}
|
||||
43
src/css/SearchToolbar.css
Normal file
43
src/css/SearchToolbar.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* ===================
|
||||
SEARCH TOOLBAR
|
||||
=================== */
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--search-bg);
|
||||
border: 1px solid var(--search-border) !important;
|
||||
}
|
||||
|
||||
.search-toolbar:has(input:focus) {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px var(--shadow-color);
|
||||
border-color: var(--accent-color) !important;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.search-toolbar.focused {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px var(--shadow-color);
|
||||
border-color: var(--accent-color) !important;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.search-toolbar input.search-input {
|
||||
all: unset;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
font-size: 1.1rem;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
padding-right: 1rem;
|
||||
background: transparent !important;
|
||||
color: var(--search-input-color);
|
||||
}
|
||||
|
||||
.search-toolbar input.search-input::placeholder {
|
||||
color: var(--search-placeholder);
|
||||
}
|
||||
61
src/css/ThemeButton.css
Normal file
61
src/css/ThemeButton.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* ================================
|
||||
THEME TOGGLE - BASE
|
||||
================================== */
|
||||
|
||||
.theme-toggle {
|
||||
width: auto;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
padding: 0 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
background-color: var(--toggle-bg);
|
||||
color: var(--toggle-fg);
|
||||
font-family: 'Product Sans';
|
||||
font-size: 1.2rem;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
HOVER / ACTIVE STATES
|
||||
================================== */
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ================================
|
||||
LIGHT THEME
|
||||
================================== */
|
||||
|
||||
.light {
|
||||
--toggle-bg: #1e1e1e;
|
||||
--toggle-fg: #f0f0f0;
|
||||
}
|
||||
|
||||
.light .theme-toggle {
|
||||
box-shadow: 0 0px 10px rgba(68, 7, 182, 0.808);
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
DARK THEME
|
||||
================================== */
|
||||
|
||||
.dark {
|
||||
--toggle-bg: #f0f0f0;
|
||||
--toggle-fg: #1e1e1e;
|
||||
}
|
||||
|
||||
.dark .theme-toggle {
|
||||
box-shadow: 0 0px 10px rgba(206, 180, 36, 0.589);
|
||||
}
|
||||
272
src/css/index.css
Normal file
272
src/css/index.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* ================================
|
||||
FUENTES PERSONALIZADAS
|
||||
================================== */
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: url('/fonts/FiraCode.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
src: url('/fonts/OpenSans.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Product Sans";
|
||||
src: url('/fonts/ProductSansRegular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Product Sans Italic";
|
||||
src: url('/fonts/ProductSansItalic.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Product Sans Italic Bold";
|
||||
src: url('/fonts/ProductSansBoldItalic.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Product Sans Bold";
|
||||
src: url('/fonts/ProductSansBold.ttf');
|
||||
}
|
||||
|
||||
/* ================================
|
||||
PALETA DE COLORES
|
||||
================================== */
|
||||
|
||||
:root {
|
||||
--highlight-border: var(--accent-color);
|
||||
--box-shadow-soft: 0 4px 6px var(--shadow-color);
|
||||
--alert-bg: #f8d7da;
|
||||
}
|
||||
|
||||
.light {
|
||||
--primary-color: #333;
|
||||
--secondary-color: #555;
|
||||
--tertiary-color: #777;
|
||||
--border-color: #dee2e6;
|
||||
--divider-color: #ddd;
|
||||
--bg-color: #fff;
|
||||
--fg-color: #111;
|
||||
--text-color: #111;
|
||||
--muted-color: #666;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--bg-hover-color: #f0f0f0;
|
||||
--bg-search-bar: rgba(0,0,0,0.05);
|
||||
--input-bg: #fff;
|
||||
--input-border: var(--border-color);
|
||||
--placeholder-color: #999;
|
||||
--input-text: var(--text-color);
|
||||
--accent-color: #333;
|
||||
--btn-bg: #333;
|
||||
--btn-bg-hover: #555;
|
||||
--btn-text: #fff;
|
||||
--btn-text-hover: #fff;
|
||||
--icon-color: var(--fg-color);
|
||||
--highlight-border: #777;
|
||||
--card-bg: #fff;
|
||||
--card-button: #fff;
|
||||
--card-border: var(--border-color);
|
||||
--card-text: var(--text-color);
|
||||
--card-text-secondary: #555;
|
||||
--card-btn-hover: rgba(0,0,0,0.05);
|
||||
--card-muted-text: #666;
|
||||
--item-bg: #fff;
|
||||
--item-text: var(--text-color);
|
||||
--subtitle-color: #666;
|
||||
--login-bg: #f9f9f9;
|
||||
--label-color: var(--text-color);
|
||||
--login-btn-bg: #333;
|
||||
--login-btn-hover: #555;
|
||||
--login-btn-text: #fff;
|
||||
--login-btn-text-hover: #111;
|
||||
--header-mask-color: rgba(0,0,0,0.1);
|
||||
--navbar-bg: #fff;
|
||||
--navbar-brand-color: #333;
|
||||
--navbar-brand-hover: #555;
|
||||
--navbar-link-color: #111;
|
||||
--navbar-link-hover-bg: #f0f0f0;
|
||||
--navbar-link-hover-color: #333;
|
||||
--navbar-dropdown-bg: #fff;
|
||||
--navbar-dropdown-item-color: #111;
|
||||
--navbar-dropdown-item-hover-color: #333;
|
||||
--navbar-divider-color: #ccc;
|
||||
--hamburger-color: #333;
|
||||
--navbar-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
--show-btn-color: #333;
|
||||
--show-btn-hover: #555;
|
||||
--header-btn-hover: rgba(0,0,0,0.05);
|
||||
--list-hover-bg: rgba(0,0,0,0.03);
|
||||
--list-hover-bg-light: #f5f5f5;
|
||||
--list-active-bg-light: #e0e0e0;
|
||||
--search-bg: rgba(255,255,255,0.6);
|
||||
--search-border: var(--border-color);
|
||||
--search-input-color: #111;
|
||||
--search-placeholder: #999;
|
||||
--toolbar-btn-color: #111;
|
||||
--toolbar-btn-hover: rgba(0,0,0,0.07);
|
||||
--modal-bg: #fff;
|
||||
--modal-header-border: var(--border-color);
|
||||
--modal-body-bg: #fff;
|
||||
--modal-close-color: #111;
|
||||
--contact-info-bg: #f5f5f5;
|
||||
--balance-report-bg: #fff;
|
||||
--file-card-bg: #fff;
|
||||
--sidebar-bg: #eee;
|
||||
--code-bg: #fffffe;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary-color: #eee;
|
||||
--secondary-color: #ccc;
|
||||
--tertiary-color: #999;
|
||||
--border-color: #555;
|
||||
--divider-color: #555;
|
||||
--bg-color: #111;
|
||||
--fg-color: #fff;
|
||||
--text-color: #fff;
|
||||
--muted-color: #aaa;
|
||||
--shadow-color: rgba(0,0,0,0.5);
|
||||
--bg-hover-color: #1e1e1e;
|
||||
--bg-search-bar: rgba(255,255,255,0.05);
|
||||
--input-bg: #1e1e1e;
|
||||
--input-border: var(--border-color);
|
||||
--placeholder-color: #888;
|
||||
--input-text: #fff;
|
||||
--accent-color: #eee;
|
||||
--btn-bg: #eee;
|
||||
--btn-bg-hover: #ccc;
|
||||
--btn-text: #111;
|
||||
--btn-text-hover: #000;
|
||||
--icon-color: #fff;
|
||||
--highlight-border: #999;
|
||||
--alert-bg: #500;
|
||||
--card-bg: #1e1e1e;
|
||||
--card-button: #1e1e1e;
|
||||
--card-border: var(--border-color);
|
||||
--card-text: #fff;
|
||||
--card-text-secondary: #ccc;
|
||||
--card-btn-hover: rgba(255,255,255,0.05);
|
||||
--item-bg: #1e1e1e;
|
||||
--item-text: #fff;
|
||||
--subtitle-color: #aaa;
|
||||
--login-bg: #111;
|
||||
--label-color: #fff;
|
||||
--login-btn-bg: #eee;
|
||||
--login-btn-hover: #ccc;
|
||||
--login-btn-text: #111;
|
||||
--login-btn-text-hover: #000;
|
||||
--header-mask-color: rgba(0,0,0,0.3);
|
||||
--navbar-bg: #111;
|
||||
--navbar-brand-color: #eee;
|
||||
--navbar-brand-hover: #ccc;
|
||||
--navbar-link-color: #fff;
|
||||
--navbar-link-hover-bg: #1e1e1e;
|
||||
--navbar-link-hover-color: #ccc;
|
||||
--navbar-dropdown-bg: #1e1e1e;
|
||||
--navbar-dropdown-item-color: #fff;
|
||||
--navbar-dropdown-item-hover-color: #ccc;
|
||||
--navbar-divider-color: #555;
|
||||
--hamburger-color: #eee;
|
||||
--navbar-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
||||
--show-btn-color: #eee;
|
||||
--show-btn-hover: #ccc;
|
||||
--card-muted-text: #aaa;
|
||||
--header-btn-hover: rgba(255,255,255,0.05);
|
||||
--list-hover-bg: rgba(255,255,255,0.03);
|
||||
--list-hover-bg-dark: #333;
|
||||
--list-active-bg-dark: #444;
|
||||
--search-bg: rgba(255,255,255,0.1);
|
||||
--search-border: var(--border-color);
|
||||
--search-input-color: #fff;
|
||||
--search-placeholder: #888;
|
||||
--toolbar-btn-color: #fff;
|
||||
--toolbar-btn-hover: rgba(255,255,255,0.08);
|
||||
--modal-bg: #1e1e1e;
|
||||
--modal-header-border: var(--border-color);
|
||||
--modal-body-bg: #1e1e1e;
|
||||
--modal-close-color: #fff;
|
||||
--contact-info-bg: #111;
|
||||
--balance-report-bg: #1e1e1e;
|
||||
--file-card-bg: #1e1e1e;
|
||||
--sidebar-bg: #000;
|
||||
--code-bg: #1e1e1e;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
ESTILOS BASE / RESET SUAVE
|
||||
================================== */
|
||||
html,
|
||||
body {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: transparent !important; /* compatibilidad navbar fija */
|
||||
}
|
||||
|
||||
main {
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tipografía global */
|
||||
div,
|
||||
label,
|
||||
input,
|
||||
p,
|
||||
span,
|
||||
a,
|
||||
button {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Product Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.custom-border {
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.custom-border-top {
|
||||
border-top: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.custom-border-bottom {
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.custom-border-left {
|
||||
border-left: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.custom-border-right {
|
||||
border-right: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.custom-text-muted {
|
||||
color: var(--muted-color) !important;
|
||||
}
|
||||
|
||||
div.fill {
|
||||
height: calc(100% - 71px);
|
||||
}
|
||||
|
||||
4
src/hooks/useAuth.js
Normal file
4
src/hooks/useAuth.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "@/context/AuthContext";
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
4
src/hooks/useConfig.js
Normal file
4
src/hooks/useConfig.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { ConfigContext } from "@/context/ConfigContext.jsx";
|
||||
|
||||
export const useConfig = () => useContext(ConfigContext);
|
||||
133
src/hooks/useData.js
Normal file
133
src/hooks/useData.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export const useData = (config) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [dataLoading, setLoading] = useState(true);
|
||||
const [dataError, setError] = useState(null);
|
||||
const configRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.baseUrl) {
|
||||
configRef.current = config;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${localStorage.getItem("token")}`,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const current = configRef.current;
|
||||
if (!current?.baseUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get(current.baseUrl, {
|
||||
headers: getAuthHeaders(),
|
||||
params: current.params,
|
||||
});
|
||||
setData(response.data.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.baseUrl) {
|
||||
fetchData();
|
||||
}
|
||||
}, [config, fetchData]);
|
||||
|
||||
const getData = async (url, params = {}, headers = {}) => {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: { ...getAuthHeaders(), ...headers },
|
||||
params,
|
||||
});
|
||||
return { data: response.data.data, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
status: err.response?.data?.status || err.response?.status,
|
||||
message: err.response?.data?.message || err.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const postData = async (endpoint, payload) => {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||
};
|
||||
const response = await axios.post(endpoint, payload, { headers });
|
||||
await fetchData();
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
const postDataValidated = async (endpoint, payload) => {
|
||||
try {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||
};
|
||||
const response = await axios.post(endpoint, payload, { headers });
|
||||
return { data: response.data.data, errors: null };
|
||||
} catch (err) {
|
||||
const raw = err.response?.data?.message;
|
||||
let parsed = {};
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { data: null, errors: { general: raw || err.message } };
|
||||
}
|
||||
|
||||
return { data: null, errors: parsed };
|
||||
}
|
||||
};
|
||||
|
||||
const putData = async (endpoint, payload) => {
|
||||
const response = await axios.put(endpoint, payload, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
await fetchData();
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
const deleteData = async (endpoint) => {
|
||||
const response = await axios.delete(endpoint, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
await fetchData();
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
const deleteDataWithBody = async (endpoint, payload) => {
|
||||
const response = await axios.delete(endpoint, {
|
||||
headers: getAuthHeaders(),
|
||||
data: payload,
|
||||
});
|
||||
await fetchData();
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
dataLoading,
|
||||
dataError,
|
||||
getData,
|
||||
postData,
|
||||
postDataValidated,
|
||||
putData,
|
||||
deleteData,
|
||||
deleteDataWithBody,
|
||||
};
|
||||
};
|
||||
4
src/hooks/useDataContext.js
Normal file
4
src/hooks/useDataContext.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { DataContext } from "@/context/DataContext";
|
||||
|
||||
export const useDataContext = () => useContext(DataContext);
|
||||
22
src/hooks/useSearchBar.js
Normal file
22
src/hooks/useSearchBar.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export const useSearchBar = (initialFilters = {}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
|
||||
const isSearching = searchTerm.trim() !== "";
|
||||
const isFiltering = Object.keys(filters).length > 0;
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filters,
|
||||
setFilters,
|
||||
isSearching,
|
||||
isFiltering,
|
||||
reset: () => {
|
||||
setSearchTerm("");
|
||||
setFilters(initialFilters);
|
||||
},
|
||||
};
|
||||
};
|
||||
103
src/hooks/useSessionRenewal.jsx
Normal file
103
src/hooks/useSessionRenewal.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { parseJwt } from "../util/tokenUtils.js";
|
||||
import NotificationModal from "../components/NotificationModal.jsx";
|
||||
import axios from "axios";
|
||||
import { useAuth } from "./useAuth.js";
|
||||
import { useConfig } from "./useConfig.js";
|
||||
|
||||
const useSessionRenewal = () => {
|
||||
const { logout } = useAuth();
|
||||
const { config } = useConfig();
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [alreadyWarned, setAlreadyWarned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
const decoded = parseJwt(token);
|
||||
|
||||
if (!token || !decoded?.exp) return;
|
||||
|
||||
const now = Date.now();
|
||||
const expTime = decoded.exp * 1000;
|
||||
const timeLeft = expTime - now;
|
||||
|
||||
if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) {
|
||||
setShowModal(true);
|
||||
setAlreadyWarned(true);
|
||||
}
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
logout();
|
||||
}
|
||||
}, 10000); // revisa cada 10 segundos
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [alreadyWarned, logout]);
|
||||
|
||||
const handleRenew = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
const decoded = parseJwt(token);
|
||||
const now = Date.now();
|
||||
const expTime = decoded?.exp * 1000;
|
||||
|
||||
if (!token || !decoded || now > expTime) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
|
||||
null,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const newToken = response.data.data.token;
|
||||
localStorage.setItem("token", newToken);
|
||||
setShowModal(false);
|
||||
setAlreadyWarned(false);
|
||||
} catch (err) {
|
||||
console.error("Error renovando sesión:", err);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
const modal = showModal && (
|
||||
<NotificationModal
|
||||
show={true}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
logout();
|
||||
}}
|
||||
title="¿Quieres seguir conectado?"
|
||||
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
|
||||
variant="info"
|
||||
buttons={[
|
||||
{
|
||||
label: "Renovar sesión",
|
||||
variant: "success",
|
||||
onClick: handleRenew,
|
||||
},
|
||||
{
|
||||
label: "Cerrar sesión",
|
||||
variant: "danger",
|
||||
onClick: () => {
|
||||
logout();
|
||||
setShowModal(false);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return { modal };
|
||||
};
|
||||
|
||||
export default useSessionRenewal;
|
||||
10
src/hooks/useTheme.js
Normal file
10
src/hooks/useTheme.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { ThemeContext } from "../context/ThemeContext";
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme debe usarse dentro de un <ThemeProvider>");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
33
src/main.jsx
Normal file
33
src/main.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
/* COMPONENTS */
|
||||
import App from '@/App.jsx'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ThemeProvider } from '@/context/ThemeContext'
|
||||
import { AuthProvider } from '@/context/AuthContext'
|
||||
import { ConfigProvider } from '@/context/ConfigContext.jsx'
|
||||
|
||||
/* CSS */
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import '@/css/index.css'
|
||||
import { SearchProvider } from './context/SearchContext'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<SearchProvider>
|
||||
<App />
|
||||
</SearchProvider>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
17
src/pages/Building.jsx
Normal file
17
src/pages/Building.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import '@/css/Building.css';
|
||||
|
||||
export default function Building() {
|
||||
const location = useLocation();
|
||||
|
||||
if (location.pathname === '/') return null;
|
||||
|
||||
return (
|
||||
<div className="building-container d-flex flex-column align-items-center justify-content-center text-center py-5 px-3">
|
||||
<div className="building-icon">🚧</div>
|
||||
<div className="building-title">Esta página está en construcción</div>
|
||||
<div className="building-subtitle">Estamos trabajando para traértela pronto</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
81
src/pages/Home.jsx
Normal file
81
src/pages/Home.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import '@/css/Home.css';
|
||||
import PastePanel from '@/components/Pastes/PastePanel';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
import LoadingIcon from '@/components/LoadingIcon';
|
||||
import { useDataContext } from '@/hooks/useDataContext';
|
||||
import { useState } from 'react';
|
||||
import { DataProvider } from '@/context/DataContext';
|
||||
import NotificationModal from '@/components/NotificationModal';
|
||||
import { useSearch } from "@/context/SearchContext";
|
||||
|
||||
const Home = () => {
|
||||
const { config, configLoading } = useConfig();
|
||||
|
||||
if (configLoading) return <p><LoadingIcon /></p>;
|
||||
|
||||
const reqConfig = {
|
||||
baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`,
|
||||
params: {
|
||||
_sort: 'created_at',
|
||||
_order: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<DataProvider config={reqConfig}>
|
||||
<HomeContent reqConfig={reqConfig} />
|
||||
</DataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeContent = ({ reqConfig }) => {
|
||||
const { data, dataLoading, dataError, postData } = useDataContext();
|
||||
const [error, setError] = useState(null);
|
||||
const [key, setKey] = useState(null);
|
||||
const { searchTerm } = useSearch();
|
||||
|
||||
if (dataLoading) return <p><LoadingIcon /></p>;
|
||||
if (dataError) return <p>Error loading data</p>;
|
||||
|
||||
const filtered = data.filter(paste =>
|
||||
paste.title.toLowerCase().includes((searchTerm ?? "").toLowerCase()) ||
|
||||
paste.content.toLowerCase().includes((searchTerm ?? "").toLowerCase())
|
||||
);
|
||||
|
||||
const handleSubmit = async (paste) => {
|
||||
try {
|
||||
const createdPaste = await postData(reqConfig.baseUrl, paste);
|
||||
if (createdPaste && createdPaste.is_private) {
|
||||
setKey(createdPaste.paste_key);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PastePanel onSubmit={handleSubmit} publicPastes={filtered} />
|
||||
<NotificationModal
|
||||
show={key !== null}
|
||||
onClose={() => setKey(null)}
|
||||
title="Link a tu paste privado"
|
||||
message={
|
||||
<span>
|
||||
Tu paste privado ha sido creado. Puedes acceder a él mediante el siguiente enlace:
|
||||
<br /><br />
|
||||
<a href={`https://paste.miarma.net/${key}`}>https://paste.miarma.net/${key}</a>
|
||||
<br /><br />
|
||||
Recuerda que este enlace es único y no se puede recuperar si se pierde.
|
||||
</span>
|
||||
}
|
||||
variant=""
|
||||
buttons={[
|
||||
{ label: "Cerrar", variant: "secondary", onClick: () => setKey(null) }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
15
src/util/alertHelpers.jsx
Normal file
15
src/util/alertHelpers.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const renderErrorAlert = (error, options = {}) => {
|
||||
const { className = 'alert alert-danger alert-dismissible py-1 px-2 small', role = 'alert' } = options;
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className={className} role={role}>
|
||||
{typeof error === 'string' ? error : 'An unexpected error occurred.'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const resetErrorIfEditEnds = (editMode, setError) => {
|
||||
if (!editMode) setError(null);
|
||||
};
|
||||
7
src/util/constants.js
Normal file
7
src/util/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const CONSTANTS = {
|
||||
|
||||
};
|
||||
|
||||
export { CONSTANTS };
|
||||
10
src/util/date.js
Normal file
10
src/util/date.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const getNowAsLocalDatetime = () => {
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset(); // en minutos
|
||||
const local = new Date(now.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
export { getNowAsLocalDatetime }
|
||||
30
src/util/parsers/dateParser.js
Normal file
30
src/util/parsers/dateParser.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export const DateParser = {
|
||||
sqlToString: (sqlDate) => {
|
||||
const [datePart] = sqlDate.split('T');
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
},
|
||||
|
||||
timestampToString: (timestamp) => {
|
||||
const [datePart] = timestamp.split('T');
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
},
|
||||
|
||||
isoToStringWithTime: (isoString) => {
|
||||
if (!isoString) return '—';
|
||||
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date)) return '—'; // Para proteger aún más por si llega basura
|
||||
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'Europe/Madrid'
|
||||
}).format(date);
|
||||
}
|
||||
};
|
||||
10
src/util/parsers/errorParser.js
Normal file
10
src/util/parsers/errorParser.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const errorParser = (err) => {
|
||||
const message = err.response?.data?.message;
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
return Object.values(parsed)[0];
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return message || err.message || "Unknown error";
|
||||
}
|
||||
};
|
||||
29
src/util/passwordGenerator.js
Normal file
29
src/util/passwordGenerator.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export const generateSecurePassword = (length = 12) => {
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const digits = '0123456789';
|
||||
const symbols = '!@#$%^&*'; // <- compatibles con bcrypt
|
||||
const all = upper + lower + digits + symbols;
|
||||
|
||||
if (length < 8) length = 8;
|
||||
|
||||
const getRand = (chars) => chars[Math.floor(Math.random() * chars.length)];
|
||||
|
||||
let password = [
|
||||
getRand(upper),
|
||||
getRand(lower),
|
||||
getRand(digits),
|
||||
getRand(symbols),
|
||||
];
|
||||
|
||||
for (let i = password.length; i < length; i++) {
|
||||
password.push(getRand(all));
|
||||
}
|
||||
|
||||
for (let i = password.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[password[i], password[j]] = [password[j], password[i]];
|
||||
}
|
||||
|
||||
return password.join('');
|
||||
};
|
||||
7
src/util/tokenUtils.js
Normal file
7
src/util/tokenUtils.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const parseJwt = (token) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
17
vite.config.js
Normal file
17
vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 3000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user