[REPO REFACTOR]: changed to a better git repository structure with branches

This commit is contained in:
2025-11-01 05:49:49 +01:00
parent 4d0f44e995
commit 589215b2bc
76 changed files with 3529 additions and 0 deletions

13
index.html Normal file
View 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
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

43
package.json Normal file
View 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"
}
}

View 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"
}
}
}
}

View 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

Binary file not shown.

BIN
public/fonts/OpenSans.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/images/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

389
public/images/favicon.svg Normal file
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
};

View 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};

View 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,
};

View 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);

View 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>
);
};

View 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
View 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);
}
}

View 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
View 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
View 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
View File

53
src/css/LoginForm.css Normal file
View 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
View 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);
}
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};
};

View 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
View 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);
},
};
};

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
'use strict';
const CONSTANTS = {
};
export { CONSTANTS };

10
src/util/date.js Normal file
View 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 }

View 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);
}
};

View 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";
}
};

View 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
View 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
View 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'),
},
},
})