Baseline: full site build (brand, pages, DB, agent-editable)
Establishes the deploy baseline on main so the admin agent's publish/rollback has a clean starting point. Everything built to date: sumi-e brand system, homepage, projects (DB-driven case studies), resume, about, services + website-design detail, contact form + DB, changelog, favicon + share card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
1
.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
79
.memory/hanko-seal.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# The Hanko Seal — concept note
|
||||
|
||||
**Status:** Concept, partially implemented · **Date:** 2026-07-22
|
||||
**Related:** [brand/BRAND.md](../brand/BRAND.md) · [brand/tokens.css](../brand/tokens.css)
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
The small vermillion square beside the wordmark is a **hanko** (判子) — a
|
||||
Japanese seal, also called an *inkan* (印鑑). The character on it, **印**, is
|
||||
read *in* and literally means "seal, stamp, mark."
|
||||
|
||||
When a seal appears on artwork next to the artist's signature, the signature
|
||||
and seal together are a **rakkan** (落款) — the traditional finishing gesture
|
||||
on ink paintings and calligraphy. You sign the work, then press your seal in
|
||||
red.
|
||||
|
||||
The red is not decorative. Seal ink is a vermillion paste called **shuniku**
|
||||
(朱肉), which is where the brand red comes from — `#D0342C` is named Shuniku in
|
||||
the brand guide for exactly this reason.
|
||||
|
||||
## Why it fits this brand
|
||||
|
||||
The logo is a handwritten signature: a brush stroke. A seal is what completes a
|
||||
brush stroke. The rakkan pairing already existed in the identity before the
|
||||
sumi-e direction was chosen — the seal only made it explicit.
|
||||
|
||||
It is also the origin of the **1–2% coverage rule** in the brand guide. A seal
|
||||
is small, pressed once, and means *this is finished, and it is mine*. Spread
|
||||
the same red across a page and it stops reading as a seal and starts reading as
|
||||
a generic accent colour. The restraint is not stylistic caution; it is what
|
||||
makes the mark mean something.
|
||||
|
||||
## Where it currently appears
|
||||
|
||||
- Beside the signature wordmark in the site header
|
||||
- Top-left of the full-page mobile menu
|
||||
- Previously bottom-right of the hero portrait (removed with the photo)
|
||||
|
||||
## Open — the thing to do later
|
||||
|
||||
**印 is a placeholder.** A traditional hanko carries its owner's *name*, not a
|
||||
generic character. Using 印 is close to signing a painting with the word
|
||||
"signature." It reads well, but it is not yet personal.
|
||||
|
||||
Replacing it solves a second, unrelated problem already logged in the brand
|
||||
guide: the signature wordmark is roughly 4:1, so it cannot serve as a favicon,
|
||||
social avatar, or app icon — it becomes an illegible smear at 32px. A seal is
|
||||
square and legible at any size.
|
||||
|
||||
Directions worth exploring when this is picked up:
|
||||
|
||||
1. **Initials in seal script** (篆書, *tensho*) — the archaic script style
|
||||
traditionally used on hanko. Most authentic; needs care to stay legible to a
|
||||
Western audience.
|
||||
2. **A stylised C** carved in negative space, in the manner of a *hakubun*
|
||||
(white-character) seal, where the character is cut away and the ground
|
||||
prints red.
|
||||
3. **カルロス** (Karurosu) — the name in katakana, which is how a non-Japanese
|
||||
name is conventionally rendered on a personal seal.
|
||||
4. **A mark rather than a character** — abstract, derived from the signature's
|
||||
own stroke geometry.
|
||||
|
||||
Whichever is chosen becomes the favicon, the avatar, and the small-size lockup
|
||||
across every surface. It is a brand-defining decision, not a detail — worth
|
||||
doing deliberately rather than as a quick swap.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
The seal is rendered as a token-driven element, not an image:
|
||||
|
||||
- `.ca-header-seal` and `.ca-menu-seal` in `app/src/styles.css`
|
||||
- Colour comes from `--ca-seal`, which shifts to `--ca-ink-surface-seal`
|
||||
(`#E8564A`) on ink grounds — `#D0342C` muddies against near-black
|
||||
- Square by design: `--radius` is `2px`, near-square rather than rounded
|
||||
|
||||
Swapping the placeholder for a real mark means replacing the character with an
|
||||
SVG, keeping the same token-driven colour behaviour.
|
||||
1
.pre-seed-backup/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
.pre-seed-backup/.user.ini
Normal file
|
|
@ -0,0 +1 @@
|
|||
open_basedir=/www/wwwroot/CarlosAriasPersonal/:/tmp/
|
||||
74
.pre-seed-backup/404.html
Normal file
74
.pre-seed-backup/502.html
Normal file
55
.pre-seed-backup/index.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Site is created successfully! </title>
|
||||
<style>
|
||||
.container {
|
||||
width: 60%;
|
||||
margin: 10% auto 0;
|
||||
background-color: #f0f0f0;
|
||||
padding: 2% 5%;
|
||||
border-radius: 10px
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
line-height: 2.3
|
||||
}
|
||||
|
||||
a {
|
||||
color: #20a53a
|
||||
}
|
||||
.footer {
|
||||
/* position: absolute;
|
||||
left: 0;
|
||||
bottom: 32px;
|
||||
width: 100%; */
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer .btlink {
|
||||
color: #20a53a;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Congratulations, the site is created successfully! </h1>
|
||||
<h3>This is the default index.html, this page is automatically generated by the system</h3>
|
||||
<ul>
|
||||
<li>The index.html of this page is in the site root directory</li>
|
||||
<li>You can modify, delete or overwrite this page</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--<div class="footer">
|
||||
Power by
|
||||
<a class="btlink" href="https://www.aapanel.com/new/download.html?invite_code=aapanele" target="_blank">aaPanel (The Free, Efficient and secure hosting control panel)</a>
|
||||
</div> -->
|
||||
</body>
|
||||
</html>
|
||||
BIN
.reference_files/Carlos Arias 2025 - Master Resume 2026 (1).pdf
Normal file
27
.reference_files/CarlosArias-Brand-Logo.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg width="2077" height="520" viewBox="0 0 2077 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_9_24)">
|
||||
<path d="M729.773 299.39C712.092 310.877 656.321 347.702 633.908 336.721C624.625 329.443 632.319 246.762 638.008 226.642C637.972 226.66 637.917 226.678 637.881 226.696C640.319 219.653 619.441 220.7 617.996 227.581C612.705 252.758 593.958 343.313 635.226 348.641C655.761 351.296 710.575 316.692 729.322 304.682C734.451 301.395 734.993 295.977 729.773 299.39Z" fill="black"/>
|
||||
<path d="M733.729 296.789C715.831 299.444 700.804 328.558 720.183 334.265C735.444 338.78 759.14 331.213 771.06 322.851C784.334 313.532 795.026 294.134 785.418 285.845C772.559 274.755 752.765 279.632 738.858 284.58C736.311 285.483 733.133 290.125 737.937 289.276C746.01 287.867 754.534 287.055 762.734 286.874C775.178 286.585 777.851 290.125 768.206 303.779C762.138 312.394 748.105 318.715 737.485 319.311C728.166 319.817 748.701 307.969 747.653 304.068C745.667 296.771 741.711 295.597 733.729 296.771V296.789Z" fill="black"/>
|
||||
<path d="M737.16 282.702C729.593 288.572 731.724 291.931 736.727 290.649C741.152 289.529 835.374 269.337 849.497 283.912C866.239 301.16 851.159 261.824 848.179 264.027C838.01 271.541 748.43 273.961 737.178 282.702H737.16Z" fill="black"/>
|
||||
<path d="M608.768 288.77C588.883 319.257 561.991 339.864 543.479 331.448C535.478 327.095 542.269 307.463 531.487 300.058C525.509 295.959 518.862 294.026 511.801 292.581C508.261 291.859 504.667 291.407 501.073 291.046C500.784 289.872 500.585 288.662 500.477 287.434C499.574 280.824 481.495 268.579 479.346 278.602C475.264 297.656 494.986 307.355 511.241 308.33C515.232 308.565 519.224 308.33 523.215 308.402C524.353 308.42 525.057 316.909 525.274 318.444C526.321 325.759 528.326 331.249 533.582 336.902C554.171 358.99 606.095 308.619 622.656 292.726C624.137 291.299 609.328 287.831 608.732 288.752L608.768 288.77Z" fill="black"/>
|
||||
<path d="M408.729 298.198C409.126 286.387 385.611 281.619 370.621 289.258C352.398 298.541 317.577 319.13 325.885 333.001C330.852 341.291 355.468 337.697 367.569 330.834C381.855 322.724 405.424 309.811 415.7 310.479C418.445 310.66 426.934 304.736 424.315 303.526C405.767 294.911 372.283 321.749 350.971 326.77C341.038 329.1 362.349 313.459 379.236 309.486C384.203 308.312 408.476 305.747 408.729 298.216V298.198Z" fill="black"/>
|
||||
<path d="M491.211 284.96C473.277 308.782 445.681 327.908 430.149 327.474C424.803 327.312 429.914 313.532 428.595 306.163C427.602 300.528 408.801 301.214 409.379 307.824C410.932 325.488 405.622 338.744 424.189 339.412C439.685 339.954 470.46 321.912 497.767 287.922C495.492 287.109 493.306 286.134 491.23 284.978L491.211 284.96Z" fill="black"/>
|
||||
<path d="M1256.87 262.691C1245.91 266.827 1256.62 278.765 1262.11 282.901C1273.16 291.227 1283.78 290.504 1296.64 290.306C1291.71 286.459 1286.8 282.594 1281.87 278.747C1281.09 295.308 1270.47 306.596 1282.95 321.496C1291.96 332.278 1303.59 337.263 1317.54 337.968C1333.72 338.762 1349.58 335.06 1364.4 328.883C1372.73 325.415 1380.59 320.846 1388.39 316.367C1394.11 313.08 1401.01 309.432 1407.86 309.432C1410.17 309.432 1404.68 299.173 1403.02 299.119C1393.97 298.758 1387.11 308.547 1382.46 312.322C1371.43 321.243 1318.19 346.998 1296.82 324.91C1287.99 315.771 1300.72 297.006 1301.9 287.109C1302.62 280.986 1291.93 275.713 1287.12 275.55C1282.55 275.388 1278.13 274.141 1273.76 272.913C1270.22 271.776 1270.62 272.263 1274.91 274.394C1274.06 272.372 1272.91 270.529 1271.46 268.85C1268.43 264.66 1262.22 260.686 1256.94 262.691H1256.87Z" fill="black"/>
|
||||
<path d="M1661.59 257.851C1645.37 264.118 1626.16 281.059 1626.23 299.86C1626.3 318.444 1651.37 319.618 1662.44 328.395C1662.38 325.993 1662.35 323.591 1662.29 321.189C1660.24 323.555 1657.62 324.874 1654.44 325.108C1653.45 330.147 1652.45 335.168 1651.46 340.207C1714.22 320.16 1778.57 304.555 1844.18 298.216C1919.71 290.92 1988.65 281.655 2069.83 294.243C2071.42 294.496 2079.13 293.177 2076.35 292.69C2000.79 279.018 1922.89 284.093 1847.02 292.635C1809.6 296.844 1772.34 302.19 1735.21 308.475C1707.41 313.188 1678.24 317.288 1651.4 326.048C1643.08 328.775 1633.47 344.939 1648.42 341.146C1661.45 337.841 1675.35 331.971 1682.97 320.322C1684.69 317.685 1685.16 315.374 1682.83 313.098C1675.42 305.874 1664.97 304.23 1656.7 298.343C1644.09 289.367 1658.27 272.624 1666.54 266.881C1674.56 261.318 1670.31 254.473 1661.54 257.869L1661.59 257.851Z" fill="black"/>
|
||||
<path d="M1554.96 282.81C1555.36 270.999 1531.83 266.231 1516.85 273.87C1498.63 283.154 1463.81 303.761 1472.12 317.613C1477.08 325.903 1501.7 322.309 1513.78 315.446C1528.07 307.337 1551.64 294.423 1561.91 295.074C1564.66 295.254 1573.13 286.35 1571.29 284.111C1564.46 275.821 1518.5 306.343 1497.19 311.364C1487.25 313.694 1508.56 298.054 1525.47 294.08C1530.43 292.906 1554.69 290.324 1554.96 282.81Z" fill="black"/>
|
||||
<path d="M1559.02 290.107C1554.83 333.109 1617.4 311.581 1637.91 301.503C1639.39 300.781 1641.71 297.765 1638.62 297.711C1617.5 297.331 1576.56 319.148 1571.96 285.104C1571.16 279.216 1559.44 285.881 1559.02 290.107Z" fill="black"/>
|
||||
<path d="M1508.17 281.293C1490.29 291.588 1461.26 328.558 1425.9 326.896C1393.21 325.343 1419.78 286.423 1413.71 284.147C1408.67 282.251 1399.57 281.275 1396.73 284.147C1392.31 288.662 1384.81 304.14 1390.14 318.245C1396.75 335.674 1413.78 337.588 1430.11 335.132C1452.27 331.791 1500.87 296.898 1510.59 285.393C1512.08 283.623 1508.83 280.896 1508.15 281.293H1508.17Z" fill="black"/>
|
||||
<path d="M1399.57 254.672C1407.1 251.999 1414.41 249.146 1421.6 245.606C1426.44 243.204 1435.04 237.749 1435.83 231.699C1435.83 231.049 1435.54 230.561 1435 230.218C1430.98 228.448 1425.39 230.363 1421.46 231.428C1413.96 233.451 1406.74 236.864 1399.53 239.718C1394.91 241.542 1387.2 246.057 1387.21 251.963C1387.23 258.031 1396.41 255.792 1399.57 254.672Z" fill="black"/>
|
||||
<path d="M866.925 252.396C850.707 258.663 831.49 275.604 831.563 294.405C831.635 312.99 856.703 314.164 867.774 322.941C867.72 320.539 867.684 318.137 867.63 315.735C865.571 318.101 862.952 319.419 859.773 319.654C858.78 324.693 857.787 329.714 856.811 334.753C919.572 314.724 1001.22 285.393 1081.18 293.358C1100.09 295.236 1085.28 289.89 1082.5 289.385C1006.93 275.713 883.613 311.816 856.775 320.593C848.449 323.32 838.841 339.485 853.795 335.692C866.817 332.387 880.724 326.517 888.345 314.868C890.061 312.231 890.53 309.919 888.201 307.644C880.796 300.438 870.339 298.776 862.067 292.888C849.461 283.912 863.638 267.17 871.91 261.427C879.929 255.864 875.685 249.019 866.907 252.415L866.925 252.396Z" fill="black"/>
|
||||
<path d="M981.159 467.173C987.299 433.219 1000.92 399.915 1013.29 367.876C1030.7 322.779 1049.9 278.331 1069.66 234.228C1088.37 192.472 1107.85 151.023 1128.41 110.133C1140.2 86.6547 1151.52 60.9545 1167.72 40.0584C1168.41 39.1734 1166.98 40.7447 1168.66 39.0831C1165.81 41.9006 1153.33 38.6316 1157.85 44.1581C1160.95 47.9689 1162.99 52.6285 1164.76 57.1617C1169.95 70.5446 1171.59 85.1737 1172.91 99.3512C1176.61 138.868 1175.11 178.872 1173.45 218.443C1171.81 257.634 1168.9 296.771 1168 335.981C1167.69 349.165 1166.89 362.801 1169.11 375.859C1170.43 383.643 1174.08 388.79 1177.4 392.835C1180.06 396.068 1189.52 390.144 1187.27 387.363C1185.84 385.629 1187.45 379.705 1187.46 377.665C1187.48 371.578 1187.92 365.456 1187.79 359.369C1187.43 341.923 1188.26 324.44 1189.05 307.03C1191.13 260.704 1193.14 214.289 1193.46 167.909C1193.73 129.603 1194.45 89.1109 1183.96 51.9061C1181.83 44.3749 1176.97 27.4882 1166.35 29.9806C1157.86 31.9673 1152.44 38.8664 1147.71 45.6572C1130.93 69.8583 1117.84 97.0214 1104.39 123.137C1083.36 163.99 1063.61 205.475 1044.52 247.249C1024.87 290.233 1006.17 333.651 989.25 377.791C977.131 409.415 964.651 441.96 959.377 475.589C958.113 483.626 979.768 475.029 981.195 467.155L981.159 467.173Z" fill="black"/>
|
||||
<path d="M1247.8 271.92C1247.8 271.92 1248.08 271.739 1247.93 271.739C1247.84 271.739 1247.66 271.92 1247.8 271.92Z" fill="black"/>
|
||||
<path d="M410.499 400.71C506.039 389.531 821.792 362.584 1065.48 363.252C1185.88 363.577 1274.05 361.844 1378.33 363.686C1463.99 365.203 1549.45 374.179 1635.04 376.346C1636.59 376.382 1641.6 372.626 1638.62 372.066C1595.8 363.921 1551.35 362.078 1507.91 359.568C1453.73 356.425 1399.44 354.71 1345.19 353.716C1222.05 351.477 1098.81 352.723 975.686 355.649C790.565 360.038 604.325 365.275 420.215 386.551C415.104 387.147 409.776 389.621 406.796 393.991C404.448 397.405 405.984 401.234 410.517 400.71H410.499Z" fill="black"/>
|
||||
<path d="M1245.04 328.251C1249.57 321.424 1254.18 314.579 1257.61 307.102C1261.48 298.686 1260.83 291.516 1258.93 282.811C1258.66 281.528 1256.17 282.883 1255.97 283.714C1252.3 299.535 1243.71 314.868 1235.34 328.648C1231.69 334.662 1242.96 331.357 1245.02 328.233L1245.04 328.251Z" fill="black"/>
|
||||
<path d="M259.422 216.763C281.383 192.399 300.04 163.394 313.025 133.558C326.318 102.981 340.351 67.6548 339.015 33.7009C338.021 8.30776 319.365 -3.35936 295.398 0.848748C264.876 6.21274 237.352 30.2694 215.101 50.2083C183.585 78.455 155.808 110.964 129.783 144.232C104.751 176.217 81.1638 209.701 61.0263 244.992C43.5256 275.658 28.7701 308.095 17.392 341.543C5.11078 377.646 -4.46133 417.903 2.22109 456.083C7.15162 484.258 25.0858 514.293 55.4275 519.079C84.5592 523.684 115.208 510.59 140.673 497.966C206.269 465.457 264.515 414.671 313.206 360.597C323.934 348.695 333.741 336.143 341.688 322.201C345.011 316.367 334.626 319.979 332.711 322.58C314.886 346.799 292.418 368.092 270.529 388.555C242.571 414.689 212.031 438.529 180.316 459.912C154.255 477.485 125.232 494.679 94.1855 501.416C83.909 503.637 72.2961 504.359 62.2725 500.531C48.3478 495.203 38.3422 481.82 32.003 468.888C15.4234 435.115 22.9907 392.474 32.3822 357.816C42.1169 321.893 57.1252 287.037 74.662 254.257C92.7045 220.519 114.594 188.715 137.567 158.192C163.43 123.841 191.55 90.9168 222.795 61.3336C240.765 44.3205 264.208 23.1536 286.783 17.7896C292.978 16.3086 299.769 17.013 305.946 21.9977C313.513 28.1021 313.928 39.3719 313.694 48.2216C312.881 79.4303 285.375 172.045 250.373 212.23C247.682 215.318 238.201 225.017 244.775 226.353C250.861 227.581 255.466 221.188 259.458 216.763H259.422Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9_24">
|
||||
<rect width="2076.97" height="520" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
BIN
.reference_files/CarlosArias-Resume-Dark.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
.reference_files/CarlosArias-Resume-Light.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
.reference_files/CarlosAriasAgentic.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
.reference_files/CarlosAriasOrbs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
.reference_files/WhatsApp Image 2026-07-21 at 8.21.24 PM.jpeg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
.reference_files/WhatsApp Image 2026-07-21 at 8.21.47 PM.jpeg
Normal file
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 46 KiB |
BIN
.reference_files/WhatsApp Image 2026-07-21 at 8.23.10 PM.jpeg
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.reference_files/WhatsApp Image 2026-07-21 at 8.23.11 PM.jpeg
Normal file
|
After Width: | Height: | Size: 43 KiB |
163
.reference_files/carlos-arias-positioning.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Carlos Arias — Repositioning Kit
|
||||
|
||||
**From:** Digital Marketing Expert & Engineer
|
||||
**To:** Agentic AI & Automation Engineer
|
||||
|
||||
---
|
||||
|
||||
## Positioning statement (the one line that decides everything)
|
||||
|
||||
**Carlos Arias**
|
||||
**Agentic AI & Automation Engineer**
|
||||
*Building autonomous software, AI agents, and intelligent business systems.*
|
||||
|
||||
> Not an ML researcher who trains foundation models. An engineer who builds AI-powered products, agents, and autonomous workflows that replace work teams used to do by hand.
|
||||
|
||||
**Tagline options** (pick one, use everywhere — site, LinkedIn, email signature):
|
||||
|
||||
1. *Building autonomous software, AI agents & intelligent business systems.*
|
||||
2. *I build software that builds, improves, and manages software.*
|
||||
3. *Autonomous systems that do the work teams used to do by hand.*
|
||||
4. *Agentic AI for founders and CTOs who want automation, not another dashboard.*
|
||||
|
||||
---
|
||||
|
||||
## Hero section
|
||||
|
||||
# Carlos Arias
|
||||
### Agentic AI & Automation Engineer
|
||||
|
||||
I design and build autonomous software, AI agents, and intelligent business systems that automate the complex workflows companies used to hand to whole teams.
|
||||
|
||||
Over 26 years in software engineering and product development, building scalable platforms across FinTech, healthcare, and SaaS. Today my focus is singular: **Agentic AI — autonomous websites, business automation, and AI systems that continuously improve, adapt, and reduce manual work.**
|
||||
|
||||
My work sits where software engineering, AI orchestration, automation, and product strategy meet — and I build the systems that live there.
|
||||
|
||||
**[ Let's build your autonomous system → ]**
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
I've been writing software since I was eight years old, on a Commodore 64 in 1988. Nearly four decades later, the instinct is the same: if a process is repetitive, it should build and run itself.
|
||||
|
||||
For the last 26 years I've worked as a Software Engineer, CTO, Product Engineer, and Technology Advisor across startups and enterprise organizations — leading engineering teams and shipping platforms in FinTech, healthcare, SaaS, digital marketing, and geospatial technology.
|
||||
|
||||
Since 2022 I've focused almost exclusively on **Agentic AI, autonomous software, and intelligent automation.**
|
||||
|
||||
I don't just bolt AI onto existing applications. I design systems where multiple AI agents collaborate — researching, planning, deciding, generating content, writing software, running QA, and optimizing continuously, with minimal human intervention.
|
||||
|
||||
The goal is simple:
|
||||
|
||||
> **Build software that builds, improves, and manages software.**
|
||||
|
||||
---
|
||||
|
||||
## Expertise
|
||||
|
||||
*(Capabilities, not services — this is what signals "builder," not "agency.")*
|
||||
|
||||
**Agentic AI Systems**
|
||||
Multi-agent architectures where AI agents collaborate to solve complex business problems end to end.
|
||||
|
||||
**AI Automation**
|
||||
Replacing repetitive business processes with intelligent, autonomous workflows.
|
||||
|
||||
**Autonomous Websites**
|
||||
Sites that continuously improve their own SEO, content, UX, and conversions using AI.
|
||||
|
||||
**AI Product Engineering**
|
||||
AI-first SaaS platforms designed and shipped from concept to production.
|
||||
|
||||
**Intelligent Marketing Systems**
|
||||
AI that runs technical SEO, content planning, local SEO, competitor research, analytics, and optimization on its own.
|
||||
|
||||
**Software Architecture**
|
||||
APIs, cloud architecture, automation pipelines, distributed systems, and scalable backends.
|
||||
|
||||
**Fractional CTO**
|
||||
Technology leadership for startups building AI products.
|
||||
|
||||
---
|
||||
|
||||
## Current Projects
|
||||
|
||||
**Agentic Marketing Platform** — *In development*
|
||||
An autonomous digital marketing platform where collaborative AI agents perform technical SEO, competitor research, content planning, local SEO, analytics, website optimization, and reporting — the full digital marketing process, run by agents.
|
||||
|
||||
**Medellin.co** — *2020–present*
|
||||
An AI-powered city platform using RAG, intelligent search, autonomous content generation, and AI assistants for travelers, expats, and local businesses.
|
||||
|
||||
**Snoopi.io** — *2014–present*
|
||||
A geolocation SaaS platform delivering IP intelligence, APIs, and location services for developers and enterprise applications.
|
||||
|
||||
**Crawllr** — *2025–present*
|
||||
An intelligent web crawler built for large-scale data collection, knowledge extraction, and AI indexing.
|
||||
|
||||
**Gabii** — *In development*
|
||||
An AI-powered relationship platform using intelligent profiling, compatibility analysis, and autonomous matchmaking.
|
||||
|
||||
---
|
||||
|
||||
## Research & Concepts
|
||||
|
||||
- Agentic Websites
|
||||
- Autonomous SEO Systems
|
||||
- Multi-Agent Collaboration
|
||||
- AI Product Architecture
|
||||
- Retrieval-Augmented Generation (RAG)
|
||||
- AI Knowledge Systems
|
||||
- AI Workflow Orchestration
|
||||
- AI-Driven Software Development
|
||||
- Self-Healing Applications
|
||||
- Autonomous Business Systems
|
||||
|
||||
---
|
||||
|
||||
## Industries
|
||||
|
||||
Artificial Intelligence · SaaS · FinTech · Healthcare · Legal Technology · Digital Marketing · Location Intelligence · Automation Platforms
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn
|
||||
|
||||
**Headline:**
|
||||
Agentic AI & Automation Engineer | Building autonomous software, AI agents & intelligent business systems | Fractional CTO
|
||||
|
||||
**About section:**
|
||||
I build autonomous software — AI agents and intelligent systems that automate the complex workflows companies used to hand to whole teams.
|
||||
|
||||
I've been writing code since I was eight, on a Commodore 64 in 1988. Across 26 years as a Software Engineer, CTO, and Technology Advisor, I've shipped platforms in FinTech, healthcare, SaaS, and geospatial tech. Since 2022 my focus has been singular: Agentic AI and intelligent automation.
|
||||
|
||||
Rather than bolting AI onto existing apps, I design multi-agent systems that research, plan, decide, generate, build, and optimize on their own. Currently building an autonomous marketing platform, an AI-powered city platform (Medellin.co), and a geolocation SaaS (Snoopi.io).
|
||||
|
||||
If you're a founder or CTO who wants automation that actually runs itself — not another dashboard — let's talk.
|
||||
|
||||
---
|
||||
|
||||
## Short bios (grab-and-go)
|
||||
|
||||
**One-liner:**
|
||||
Carlos Arias is an Agentic AI & Automation Engineer who builds autonomous software, AI agents, and intelligent business systems.
|
||||
|
||||
**50 words:**
|
||||
Carlos Arias is an Agentic AI & Automation Engineer. With 26 years in software engineering and product development, he designs multi-agent systems that automate complex workflows — from autonomous websites to AI-driven marketing platforms. His focus since 2022: building software that builds, improves, and manages software with minimal human intervention.
|
||||
|
||||
**100 words:**
|
||||
Carlos Arias is an Agentic AI & Automation Engineer who builds autonomous software, AI agents, and intelligent business systems. He's been coding since age eight, on a Commodore 64 in 1988, and has spent 26 years as a Software Engineer, CTO, and Technology Advisor across FinTech, healthcare, SaaS, and geospatial technology. Since 2022 he's focused entirely on Agentic AI — designing multi-agent systems that research, plan, build, and optimize on their own. He's currently building an autonomous marketing platform, an AI-powered city platform (Medellin.co), and a geolocation SaaS (Snoopi.io). His mission: build software that builds, improves, and manages software.
|
||||
|
||||
---
|
||||
|
||||
## What I cut (and why)
|
||||
|
||||
Removed from the old positioning — these read as "digital agency owner," not "AI infrastructure builder":
|
||||
|
||||
- Graphic Design
|
||||
- Social Media management
|
||||
- WordPress Developer
|
||||
- Branding
|
||||
- Website Design (as a standalone service)
|
||||
- Digital Marketing *as a headline service* — now reframed as a **domain your AI systems automate**, not something you sell by hand.
|
||||
|
||||
**One note to double-check:** the FinTech / security & compliance credentials (HIPAA, SOC2, PCI) from your original draft aren't in this version. They're a genuine trust signal for enterprise buyers — worth keeping as a single line under About or Expertise if the work backs it up. Say the word and I'll fold it in.
|
||||
1
.user.ini
Normal file
|
|
@ -0,0 +1 @@
|
|||
open_basedir=/www/wwwroot/CarlosAriasPersonal/:/tmp/
|
||||
|
|
@ -0,0 +1 @@
|
|||
eXf4NRu-MxHC0Dis4sHu5pC3gDElq1Y9p9LB0CSdHS0.yunwhBDUlOIssanN_n7Md4A3OXu6B-DP-N3ietCHx8E
|
||||
74
404.html
Normal file
74
502.html
Normal file
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"site": {
|
||||
"url": "https://example.com",
|
||||
"name": "SeedProject Site",
|
||||
"topic": "your subject area",
|
||||
"audience": "your target audience",
|
||||
"url": "https://carlosarias.co",
|
||||
"name": "Carlos Arias",
|
||||
"topic": "agentic AI, autonomous software, and business process automation",
|
||||
"audience": "founders and CTOs who want automation, not another dashboard",
|
||||
"language": "en"
|
||||
},
|
||||
"paths": {
|
||||
"projectRoot": "/www/wwwroot/seedproject-web",
|
||||
"projectRoot": "/www/wwwroot/CarlosAriasPersonal",
|
||||
"appDir": "app",
|
||||
"blogContentDir": "app/src/content/blog",
|
||||
"blogDataFile": "app/src/lib/blog-data.js",
|
||||
|
|
@ -38,8 +38,8 @@
|
|||
"maxPerScan": 8
|
||||
},
|
||||
"author": {
|
||||
"slug": "site-author",
|
||||
"name": "Site Author"
|
||||
"slug": "carlos-arias",
|
||||
"name": "Carlos Arias"
|
||||
},
|
||||
"image": {
|
||||
"provider": "higgsfield",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"outputFormat": "jpg",
|
||||
"credit": {
|
||||
"caption": "Illustrative cover image. Not a photograph of any specific establishment.",
|
||||
"author": "SeedProject Site"
|
||||
"author": "Carlos Arias"
|
||||
}
|
||||
},
|
||||
"editorial": {
|
||||
|
|
|
|||
140
agents/console/PLAN.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Admin Agent — Plan
|
||||
|
||||
**Goal:** manage the site through an AI agent (headless Claude) — create pages,
|
||||
edit content, upload images — from two surfaces: a full **/admin dashboard** and
|
||||
an **in-page console** ("open a page, tell it what to change").
|
||||
|
||||
**Decisions locked (2026-07-23):**
|
||||
- **Full-builder agent** — can create pages, edit any file, restructure. Highest
|
||||
ceiling, highest risk; sandboxing is mandatory, not optional.
|
||||
- **Both surfaces** — /admin dashboard + in-page console.
|
||||
- **Long-lived token** — `claude setup-token` on the server; agent reuses it.
|
||||
|
||||
---
|
||||
|
||||
## What already exists (don't rebuild)
|
||||
|
||||
- `app/src/components/DevConsole.astro` — the in-page console UI (prompt box,
|
||||
element picker, preview link, Publish). Already on every live page, dormant.
|
||||
- `astroagent.config.json` — engine config: model, tools
|
||||
(`Read Write Edit Glob Grep WebSearch`), confined `agentUser`
|
||||
(`carlos-arias-agent`), skills (`brand`, `ui-ux`, `seo`), console route/port.
|
||||
- `api/` auth — `account.php` sessions + `ADMIN_TOKEN`.
|
||||
- Structured content already DB-driven and agent-friendly: `cja_projects`,
|
||||
`cja_changelog`, `cja_contact`, plus the seed scripts that write them.
|
||||
- Server has the `claude` CLI and Node 22. Project is a git repo on `main`.
|
||||
|
||||
## What's missing (the build)
|
||||
|
||||
- The **runner service** the console talks to (`/devconsole/run|publish|...`) —
|
||||
frontend exists, backend does not.
|
||||
- **Image upload** anywhere in the flow.
|
||||
- **Model selector** and the structured **draft + prompt + images** panel.
|
||||
- The **/admin dashboard**.
|
||||
- **Media pipeline** (`cja_media` + optimisation).
|
||||
- **Hardening** — the code-writing agent is the highest-risk surface on the site.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (you, authed)
|
||||
├── /admin ................ dashboard SPA: pages, media, agent chat
|
||||
└── in-page console ........ DevConsole.astro on any page
|
||||
│ (same-origin fetch, session-authed)
|
||||
▼
|
||||
nginx ── /devconsole/* , /admin/* ──► localhost-only runner (Node, :3011)
|
||||
│ auth gate on every request │
|
||||
│ ├── headless Claude Code
|
||||
│ │ (agentUser, confined to repo,
|
||||
│ │ tools: Read/Write/Edit/Glob/Grep/WebSearch)
|
||||
│ ├── preview build → public-preview/<jobId>/
|
||||
│ ├── publish → build public/ + chown + git commit
|
||||
│ └── media: optimise → app/public/media/ + cja_media
|
||||
▼
|
||||
Static site (public/) — unchanged for visitors
|
||||
```
|
||||
|
||||
**Content model — hybrid:**
|
||||
- Freeform pages (`about`, `services/*`) → the agent edits `.astro` files directly.
|
||||
- New pages → the agent writes new `.astro` files.
|
||||
- Structured content (projects, changelog, services data) → DB, edited via
|
||||
structured tools or the seed JSON/scripts.
|
||||
- Images → `app/public/media/` (survives rebuilds) + `cja_media` rows.
|
||||
|
||||
**Why preview-first matters:** the site is static. Every agent run builds to an
|
||||
isolated `public-preview/<jobId>/` you can open and review; **nothing goes live
|
||||
until you press Publish**, which rebuilds `public/` and commits to git.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — Foundation & safety *(do first, no features yet)*
|
||||
- Commit the current working tree as a clean baseline (75 files) so every future
|
||||
publish is a diff you can roll back.
|
||||
- Create/confirm the confined `carlos-arias-agent` OS user: can write only the
|
||||
repo, no sudo, no interactive shell. Install/point `claude` for that user.
|
||||
- `claude setup-token` on the server → store in `agents/.env` (git-ignored).
|
||||
- Admin auth: session login (reuse `api/account.php` or a dedicated admin login).
|
||||
- nginx: `/devconsole/*` and `/admin/*` → localhost runner, **auth-gated**, never
|
||||
exposed directly. Runner binds `127.0.0.1` only.
|
||||
- Audit log table (`cja_admin_log`): who ran what, when, published or discarded.
|
||||
|
||||
### Phase 1 — In-page console (finish the astroagent)
|
||||
- Build the runner service: `run` (spawn headless Claude with prompt + page
|
||||
context, stream output), `preview`, `publish`, `discard`, `ping`, `auth`,
|
||||
`logout`. Conversation state so follow-ups work ("now make it bigger").
|
||||
- Wire `DevConsole.astro` to it. Element picker already works.
|
||||
- Publish pipeline: build → verify build passes → chown → git commit.
|
||||
- Milestone: open /about, "tighten the opening paragraph," preview, publish.
|
||||
|
||||
### Phase 2 — Media
|
||||
- `cja_media` table + `POST /devconsole/upload`.
|
||||
- Optimise on upload (resize/compress, like the manual ffmpeg step), write to
|
||||
`app/public/media/`, return URL + agent-generated alt text.
|
||||
- Image upload in the console; agent can place uploaded images in a page.
|
||||
- Milestone: on a project page, "add these three screenshots to the gallery."
|
||||
|
||||
### Phase 3 — Admin dashboard (/admin)
|
||||
- Login → dashboard.
|
||||
- **Pages**: list (filesystem scan of `app/src/pages` + DB content types),
|
||||
create-page flow ("make a page for my accounting services"), edit (hands off
|
||||
to the console or a structured form).
|
||||
- **Media library**: browse/upload/reuse.
|
||||
- **Agent panel — your exact spec**: model selector · draft-content box · prompt
|
||||
box · image-upload area. Draft + prompt + images post to the same runner.
|
||||
- **Activity log** from `cja_admin_log`.
|
||||
|
||||
### Phase 4 — Guardrails & polish
|
||||
- Feed the agent the brand guide + `brand`/`ui-ux`/`seo` skills so edits stay
|
||||
on-brand automatically.
|
||||
- "Build passes" gate is required before Publish is offered.
|
||||
- One-click **rollback** (git revert the last publish).
|
||||
- Per-session run/token limits; rate limiting.
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
1. **Security is the whole game.** A code-writing agent on a public server is the
|
||||
highest-value target here — a breach means full server control. Mitigations:
|
||||
admin auth on every request, runner bound to localhost, confined OS user,
|
||||
audit log, no direct exposure of the agent, rate limits.
|
||||
2. **The agent can break the build.** → preview-first, build-passes gate, git
|
||||
commit per publish, one-click rollback.
|
||||
3. **Unbounded token spend.** → per-session limits; the long-lived token is one
|
||||
account we can watch.
|
||||
4. **On-brand drift.** → brand guide + skills injected into every agent run;
|
||||
preview-before-publish is the human gate.
|
||||
|
||||
## Not in scope (yet)
|
||||
- Multi-user / roles (single admin for now).
|
||||
- Scheduled autonomous edits (the `agents/` content pipeline is separate and
|
||||
currently tuned for the old blog — re-tune later if wanted).
|
||||
|
||||
## To start Phase 0, I need
|
||||
- Your go-ahead to commit the current tree as the baseline.
|
||||
- A decision on admin login: reuse the existing account system, or a simple
|
||||
single-user admin password to start.
|
||||
111
api/app/Controllers/Contact.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
/**
|
||||
* Contact form endpoint.
|
||||
*
|
||||
* POST /api/contact/submit — accept a message, store it in cja_contact
|
||||
*
|
||||
* Public, so it guards origin and rate-limits (5 per 10 minutes per IP —
|
||||
* generous for a human, useless for a script). Validation is server-side and
|
||||
* authoritative; the browser's `required` attributes are a convenience, not a
|
||||
* control.
|
||||
*/
|
||||
class Contact extends PublicController
|
||||
{
|
||||
/** Subjects the form offers. The slug is stored; the label is display-only. */
|
||||
private const SUBJECTS = [
|
||||
'ai-training' => 'AI Training',
|
||||
'workflow-automation' => 'Workflow Automation',
|
||||
'software-development' => 'Software Development',
|
||||
'website-design' => 'Website Design',
|
||||
'fractional-cto' => 'Fractional CTO',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
// 5 submissions per 10 minutes per IP.
|
||||
$this->guardPublic('contact_submit', 5, 600);
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
$this->json(null, 405, ['code' => 'method_not_allowed', 'message' => 'POST only']);
|
||||
}
|
||||
|
||||
$in = $this->readBody();
|
||||
|
||||
// Honeypot: a field hidden from humans. A bot fills everything, so any
|
||||
// value here is spam. Answer 200 so the bot cannot tell it was caught,
|
||||
// but write nothing.
|
||||
if (trim((string) ($in['company'] ?? '')) !== '') {
|
||||
$this->json(['received' => true]);
|
||||
}
|
||||
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$subject = trim((string) ($in['subject'] ?? 'other'));
|
||||
$message = trim((string) ($in['message'] ?? ''));
|
||||
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 160) {
|
||||
$errors['name'] = 'Please enter your name.';
|
||||
}
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
|
||||
$errors['email'] = 'Please enter a valid email address.';
|
||||
}
|
||||
if (!isset(self::SUBJECTS[$subject])) {
|
||||
// Unknown subject is coerced rather than rejected — never trust the
|
||||
// client's list, but do not fail a real person over it either.
|
||||
$subject = 'other';
|
||||
}
|
||||
if (mb_strlen($message) < 10) {
|
||||
$errors['message'] = 'Please add a little more detail (10 characters or more).';
|
||||
}
|
||||
if (mb_strlen($message) > 5000) {
|
||||
$errors['message'] = 'That message is a bit long — please keep it under 5000 characters.';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
$this->json(null, 422, [
|
||||
'code' => 'validation_failed',
|
||||
'message' => 'Please check the highlighted fields.',
|
||||
'fields' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
\Db::insert('cja_contact', [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'status' => 'new',
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
'referrer' => mb_substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 255),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// TODO: notify. A mailer helper exists (app/Helpers/Mailer.php, Brevo);
|
||||
// wire it once a from-address and API key are configured. The row is
|
||||
// saved either way, so a mail outage never loses a lead.
|
||||
|
||||
$this->json([
|
||||
'received' => true,
|
||||
'message' => "Thanks — I'll get back to you shortly.",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Read a JSON body, falling back to form-encoded POST. */
|
||||
private function readBody(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw !== '' && $raw !== false) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
return $_POST ?: [];
|
||||
}
|
||||
}
|
||||
63
api/cli/seed-changelog.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
/**
|
||||
* Seed the public changelog with the real build history of this site.
|
||||
*
|
||||
* Idempotent-ish: this TRUNCATEs and reloads, because the changelog is a
|
||||
* curated narrative, not accreting user data. Re-running gives a clean set.
|
||||
* (Contrast with seed-projects.php, which upserts to preserve ids.)
|
||||
*
|
||||
* php api/cli/seed-changelog.php
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
require __DIR__ . '/../config.php';
|
||||
|
||||
// type, summary, detail (or null), 'YYYY-MM-DD HH:MM'
|
||||
$entries = [
|
||||
['added', 'Brand system established', 'Sumi-e direction — ink on washi paper, a single vermillion seal. Colour, type, and motion tokens.', '2026-07-21 16:40'],
|
||||
['added', 'Homepage built', 'Ink hero with the signature writing itself, a typographic work index, and a full-bleed mission line.', '2026-07-22 09:15'],
|
||||
['added', 'SeedProject cloned and wired', 'Astro frontend, PHP API, and MariaDB connected end to end.', '2026-07-22 12:05'],
|
||||
['bug', 'Base schema failed to install', 'A migration declared a foreign key before the table it referenced existed.', '2026-07-22 12:40'],
|
||||
['fixed', 'Schema install repaired', 'Added the standard foreign-key guard to the snapshot migration so tables load in any order.', '2026-07-22 12:42'],
|
||||
['added', 'Navigation and brand mark', 'Header signature paired with the hanko seal; nav updated to Projects, Resume, About, Blog, Contact.', '2026-07-22 12:16'],
|
||||
['added', 'Theme toggle', 'Light and dark, with an ink-reveal transition that spreads from the button you click.', '2026-07-22 12:49'],
|
||||
['bug', 'Hero rendered paper-on-paper in light mode', 'A scoped colour override could not reach the already-resolved theme tokens.', '2026-07-22 12:58'],
|
||||
['fixed', 'Ink surface scoping corrected', 'The dark hero now overrides the compiled colour tokens directly, in both themes.', '2026-07-22 12:58'],
|
||||
['added', 'Full-page mobile menu', 'A curved reveal with the hamburger morphing into a close control.', '2026-07-22 13:25'],
|
||||
['bug', 'Mobile menu clipped to the header', 'On inner pages the header\'s backdrop-blur trapped the fixed overlay to a thin strip.', '2026-07-22 13:44'],
|
||||
['fixed', 'Menu portalled to the page root', 'The overlay now escapes the header entirely and covers the page identically everywhere.', '2026-07-23 12:45'],
|
||||
['added', 'Animated 404 page', 'A playful full-screen 404, with a reduced-motion fallback and locally-hosted assets.', '2026-07-22 13:12'],
|
||||
['added', 'React added as Astro islands', 'Interactive components hydrate only where used; the rest of the site stays static.', '2026-07-22 12:00'],
|
||||
['added', 'Resume page', 'Experience, ventures, a grouped skills breakdown, education, and a downloadable PDF.', '2026-07-22 15:24'],
|
||||
['added', 'Portraits with light/dark variants', 'A wardrobe swap — the right shot for each theme, graded into the palette.', '2026-07-22 15:51'],
|
||||
['bug', 'A component import broke the build', 'Aliasing framer-motion to its successor created a circular module reference.', '2026-07-22 15:24'],
|
||||
['fixed', 'Motion dependency resolved', 'Declared the real package directly instead of aliasing, ending the loop.', '2026-07-22 15:24'],
|
||||
['added', 'Projects moved to the database', 'Per-project case-study pages generated from cja_projects at build time.', '2026-07-23 12:30'],
|
||||
['added', 'Project filtering', 'Filter by category with an animated, shareable, deep-linkable transition.', '2026-07-23 12:44'],
|
||||
['added', 'Contact form wired up', 'Real submissions stored in the database, with validation and spam protection.', '2026-07-23 12:14'],
|
||||
['added', 'About page', 'The story from a Commodore 64 in 1988 to agentic AI today.', '2026-07-23 13:52'],
|
||||
['fixed', 'About copy corrected', 'Separated learning to code (1988) from building software professionally (1999).', '2026-07-23 14:33'],
|
||||
['added', 'Project galleries and social rail', 'A photo gallery with a lightbox, and a sticky Instagram rail beside each case study.', '2026-07-23 14:50'],
|
||||
['added', 'This changelog', 'The site now documents its own evolution — fittingly, an agentic website that logs itself.', '2026-07-23 15:05'],
|
||||
['updated', 'About headline tuned for search', 'Led with agentic AI and automation instead of the origin year — same story, better discoverability.', '2026-07-23 15:20'],
|
||||
['added', 'Footer service links', 'Each service links straight to the contact form with the enquiry already selected.', '2026-07-23 15:35'],
|
||||
['added', 'Services page', 'Automation, website design, and digital marketing — built for law firms and service businesses.', '2026-07-23 16:05'],
|
||||
['updated', 'Contact form aligned to services', 'Enquiry types now mirror the three core services offered.', '2026-07-23 16:10'],
|
||||
['updated', 'Navigation', 'Added Services to the main menu, front and centre.', '2026-07-23 16:12'],
|
||||
];
|
||||
|
||||
Db::execute('TRUNCATE TABLE cja_changelog');
|
||||
|
||||
$n = 0;
|
||||
foreach ($entries as [$type, $summary, $detail, $at]) {
|
||||
Db::insert('cja_changelog', [
|
||||
'type' => $type,
|
||||
'summary' => $summary,
|
||||
'detail' => $detail,
|
||||
'entry_at' => $at . ':00',
|
||||
'published' => 1,
|
||||
]);
|
||||
$n++;
|
||||
}
|
||||
|
||||
echo " seeded {$n} changelog entries\n";
|
||||
373
api/cli/seed-projects.php
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
<?php
|
||||
/**
|
||||
* Seed / refresh cja_projects.
|
||||
*
|
||||
* Idempotent: upserts on slug, so it is safe to re-run after editing the
|
||||
* content below. Existing rows keep their project_id, so anything pointing at
|
||||
* them stays valid.
|
||||
*
|
||||
* php api/cli/seed-projects.php
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
require __DIR__ . '/../config.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
|
||||
DB_USER,
|
||||
DB_PASS,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projects = [
|
||||
[
|
||||
'slug' => 'agentic-marketing-platform',
|
||||
'title' => 'Agentic Marketing Platform',
|
||||
'kind' => 'SaaS',
|
||||
'period_label' => 'In development',
|
||||
'started_on' => '2025-01-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'building',
|
||||
'summary' => 'An autonomous digital marketing platform where collaborative agents run technical SEO, competitor research, content planning, local SEO, analytics, and reporting — the full process, run by agents.',
|
||||
'lede' => 'Most marketing tools give you a dashboard and leave the work to you. This one does the work.',
|
||||
'role' => 'Solo — architecture, agents, product',
|
||||
'categories' => ['Agentic', 'SaaS', 'Tools'],
|
||||
'stack' => ['Astro', 'PHP', 'MariaDB', 'LLM orchestration', 'MCP'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['Multi-agent architecture', 'Astro + PHP', 'MCP tooling']],
|
||||
['group' => 'AI', 'items' => ['LLM orchestration', 'Agent handoffs', 'Autonomous QA']],
|
||||
['group' => 'Marketing', 'items' => ['Technical SEO', 'Competitor research', 'Content planning']],
|
||||
],
|
||||
'metrics' => [
|
||||
['label' => 'Agents', 'value' => '8', 'note' => 'research, plan, write, review, publish'],
|
||||
['label' => 'Human steps', 'value' => '1', 'note' => 'approve, or let it run'],
|
||||
],
|
||||
'links' => [],
|
||||
'body' => <<<'MD'
|
||||
## The problem
|
||||
|
||||
Digital marketing is a pipeline of repetitive judgement calls. Audit the site,
|
||||
find the gaps, research what competitors rank for, plan the content, write it,
|
||||
review it, publish it, measure it, and start again. Every step is
|
||||
well-understood. Almost none of it is automated, because each step needs
|
||||
context from the one before it.
|
||||
|
||||
Tools solve this by giving you a dashboard. You still do the work.
|
||||
|
||||
## The approach
|
||||
|
||||
A set of specialist agents, each with a narrow job and a defined handoff, that
|
||||
pass context down the pipeline rather than dumping it into one prompt.
|
||||
|
||||
Research agents build the picture. Planning agents decide what to make.
|
||||
Writing agents draft against a brief. Review agents check the draft against the
|
||||
brief rather than against taste. A publishing agent ships it.
|
||||
|
||||
The interesting problem is not any single agent — it is the handoff. Most
|
||||
multi-agent systems fail because step four has forgotten why step two made the
|
||||
decision it made.
|
||||
|
||||
## Where it is
|
||||
|
||||
In development. Running against live properties, not test data.
|
||||
MD,
|
||||
],
|
||||
[
|
||||
'slug' => 'medellin-co',
|
||||
'title' => 'Medellin.co',
|
||||
'kind' => 'SaaS / Media',
|
||||
'period_label' => '2020 — present',
|
||||
'started_on' => '2022-10-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'live',
|
||||
'summary' => 'An AI-powered city platform using RAG, intelligent search, autonomous content generation, and AI assistants for travelers, expats, and local businesses.',
|
||||
'lede' => 'The largest resource hub for Medellín, Colombia — built because the information simply did not exist in one place.',
|
||||
'role' => 'Founder — product, engineering, SEO, partnerships',
|
||||
'categories' => ['Websites', 'SaaS', 'Agentic'],
|
||||
'stack' => ['WordPress', 'Custom PHP', 'OpenAI', 'Zilliz', 'Stripe'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['Custom PHP', 'Third-party integrations', 'Payment systems']],
|
||||
['group' => 'AI', 'items' => ['RAG', 'Vector search', 'Embeddings', 'Chatbot design']],
|
||||
['group' => 'Marketing', 'items' => ['Technical SEO', 'Content strategy', 'Link building']],
|
||||
['group' => 'Leadership', 'items' => ['Managing an overseas team', 'Partnerships & advertising sales']],
|
||||
],
|
||||
'metrics' => [
|
||||
['label' => 'Pages ranked', 'value' => '200+', 'note' => 'roughly 70% on page one'],
|
||||
['label' => 'Domain authority', 'value' => '10 → 30', 'note' => 'over three years'],
|
||||
],
|
||||
// Placeholders — set src to a real path once photos exist. Empty src
|
||||
// renders a labelled frame so the gallery layout is visible either way.
|
||||
'gallery' => [
|
||||
['src' => '', 'alt' => 'Medellin.co homepage', 'caption' => 'Homepage'],
|
||||
['src' => '', 'alt' => 'AI assistant interface', 'caption' => 'AI assistant'],
|
||||
['src' => '', 'alt' => 'Business listing page', 'caption' => 'Business listings'],
|
||||
['src' => '', 'alt' => 'Advertiser dashboard', 'caption' => 'Advertiser dashboard'],
|
||||
],
|
||||
'links' => [
|
||||
'live' => 'https://medellin.co',
|
||||
'instagram' => 'https://instagram.com/medellin.co',
|
||||
],
|
||||
'body' => <<<'MD'
|
||||
## The problem
|
||||
|
||||
Medellín has a large and growing population of tourists and expats, and almost
|
||||
no reliable central resource written for them. Information was scattered across
|
||||
outdated blogs, closed Facebook groups, and word of mouth.
|
||||
|
||||
The domain had recently been released by the city. I negotiated with the owner
|
||||
and acquired it in October 2022.
|
||||
|
||||
## What it does
|
||||
|
||||
A content platform with an AI layer on top. Visitors ask questions in natural
|
||||
language and get answers grounded in the site's own content rather than a
|
||||
general-purpose model's guesses — retrieval-augmented generation over an
|
||||
embedded corpus of everything the site knows.
|
||||
|
||||
Beyond content, it integrates directly with local systems: a path for users to
|
||||
file criminal reports with law enforcement, and Stripe checkout for advertisers.
|
||||
|
||||
## Engineering notes
|
||||
|
||||
- Chatbot on an OpenAI LLM with a Zilliz vector database and OpenAI embeddings
|
||||
- Custom PHP integrating directly with TikTok and Instagram
|
||||
- Custom WordPress build using Elementor and Crocoblock
|
||||
- Overseas team managed on design and development
|
||||
|
||||
## Result
|
||||
|
||||
The largest resource hub for the city, used by locals, expats, and tourists
|
||||
before they arrive. 200+ pages optimised, roughly 70% ranking on page one, and
|
||||
domain authority grown from 10 to 30.
|
||||
MD,
|
||||
],
|
||||
[
|
||||
'slug' => 'verificaa',
|
||||
'title' => 'Verificaa.com',
|
||||
'kind' => 'SaaS',
|
||||
'period_label' => '2021 — present',
|
||||
'started_on' => '2021-06-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'live',
|
||||
'summary' => 'ID verification and fraud prevention for Latin America — letting people verify businesses and individuals before doing business with them.',
|
||||
'lede' => 'Built because fraud in Latin America is common, and foreigners had no way to check who they were dealing with.',
|
||||
'role' => 'Founder — architecture, data pipeline, AI',
|
||||
'categories' => ['SaaS', 'Tools', 'Agentic'],
|
||||
'stack' => ['PHP', 'REST API', 'LLM', 'Data mining', 'Stripe'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['REST API design', 'API key management', 'Custom PHP dashboard']],
|
||||
['group' => 'Data', 'items' => ['Data mining', 'Crawling', 'Normalisation pipelines']],
|
||||
['group' => 'AI', 'items' => ['LLM fraud detection', 'Pattern analysis']],
|
||||
],
|
||||
'metrics' => [
|
||||
['label' => 'Lives saved', 'value' => '2', 'note' => 'reported by users'],
|
||||
['label' => 'Sources', 'value' => 'Police, AG, business registries'],
|
||||
],
|
||||
'links' => ['live' => 'https://verificaa.com'],
|
||||
'body' => <<<'MD'
|
||||
## The problem
|
||||
|
||||
Fraud is common across Latin America, and foreigners doing business there have
|
||||
almost no way to check who they are dealing with. The data exists — police
|
||||
records, attorney general filings, business registries — but it is scattered,
|
||||
inconsistently formatted, and effectively unsearchable.
|
||||
|
||||
## What it does
|
||||
|
||||
Verificaa normalises that data and puts a single lookup in front of it. Enter a
|
||||
person or a business, get back what the public record actually says.
|
||||
|
||||
An AI layer sits on top to flag patterns a human would miss: identities that
|
||||
appear across unrelated filings, businesses registered days before a
|
||||
transaction, documents that do not reconcile.
|
||||
|
||||
## Engineering notes
|
||||
|
||||
- Data-mining pipeline that crawls open Colombian and LATAM data and normalises it
|
||||
- Integrations with police, attorney general, and business databases
|
||||
- REST API with key management for developer access
|
||||
- Custom PHP dashboard with a complex user management system
|
||||
- LLM-driven fraud detection infrastructure
|
||||
|
||||
## Result
|
||||
|
||||
Users have reported the platform helping in two cases where someone's safety was
|
||||
at risk, alongside a steady stream of prevented financial loss.
|
||||
MD,
|
||||
],
|
||||
[
|
||||
'slug' => 'snoopi-io',
|
||||
'title' => 'Snoopi.io',
|
||||
'kind' => 'SaaS',
|
||||
'period_label' => '2015 — present',
|
||||
'started_on' => '2015-03-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'live',
|
||||
'summary' => 'A geolocation SaaS platform delivering IP intelligence, APIs, and location services for developers and enterprise applications.',
|
||||
'lede' => 'Started as a way to learn PHP OOP properly. Ten years later it is still running, and still shipping.',
|
||||
'role' => 'Founder — everything',
|
||||
'categories' => ['SaaS', 'Tools'],
|
||||
'stack' => ['PHP', 'REST API', 'WordPress', 'Stripe'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['PHP OOP', 'REST API design', 'Real-time tracking']],
|
||||
['group' => 'Product', 'items' => ['Subscription billing', 'Invoice management', 'Custom dashboard']],
|
||||
['group' => 'Marketing', 'items' => ['GeoIP landing-page optimisation']],
|
||||
],
|
||||
'metrics' => [
|
||||
['label' => 'Running since', 'value' => '2015', 'note' => 'ten years in production'],
|
||||
],
|
||||
'links' => ['live' => 'https://snoopi.io'],
|
||||
'body' => <<<'MD'
|
||||
## Origin
|
||||
|
||||
This one started as a learning project in 2015. I wanted to understand PHP
|
||||
object-oriented programming properly, and building a real REST API that other
|
||||
developers could actually use seemed like a better teacher than a tutorial.
|
||||
|
||||
Ten years later it is still in production.
|
||||
|
||||
## What it does
|
||||
|
||||
GeoIP location and IP fraud prevention. Developers hit the API to resolve an IP
|
||||
to a location; marketers use the tracking script to understand where landing
|
||||
page traffic actually comes from.
|
||||
|
||||
## Engineering notes
|
||||
|
||||
- Real-time tracking script for sites and landing pages
|
||||
- GeoIP-driven landing page optimisation
|
||||
- Custom dashboard with invoice and subscription management
|
||||
- Stripe integration using a custom card form
|
||||
|
||||
## What is next
|
||||
|
||||
AI analysis, to help marketers identify their best-performing pages rather than
|
||||
just reporting where visitors came from.
|
||||
MD,
|
||||
],
|
||||
[
|
||||
'slug' => 'crawllr',
|
||||
'title' => 'Crawllr',
|
||||
'kind' => 'Tooling',
|
||||
'period_label' => '2025 — present',
|
||||
'started_on' => '2025-01-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'live',
|
||||
'summary' => 'An intelligent web crawler built for large-scale data collection, knowledge extraction, and AI indexing.',
|
||||
'lede' => 'Crawling is a solved problem. Crawling in a way an LLM can actually use is not.',
|
||||
'role' => 'Solo — architecture and build',
|
||||
'categories' => ['Tools', 'Agentic'],
|
||||
'stack' => ['PHP', 'MCP', 'LLM', 'Vector storage'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['Crawl architecture', 'MCP integration', 'Vector storage']],
|
||||
['group' => 'AI', 'items' => ['Knowledge extraction', 'Content chunking', 'Retrieval indexing']],
|
||||
],
|
||||
'metrics' => [],
|
||||
'links' => [],
|
||||
'body' => <<<'MD'
|
||||
## The problem
|
||||
|
||||
Fetching pages is trivial. Turning a site into something a language model can
|
||||
reason over is not. Boilerplate has to go, structure has to survive, and the
|
||||
result has to be chunked in a way that keeps meaning intact across boundaries.
|
||||
|
||||
Most crawlers optimise for coverage. This one optimises for what comes after.
|
||||
|
||||
## What it does
|
||||
|
||||
Large-scale collection with knowledge extraction built into the pipeline rather
|
||||
than bolted on afterwards. Output is indexed for retrieval, not just archived.
|
||||
|
||||
It is exposed to agents over MCP, so an agent can crawl and reason in one loop
|
||||
instead of waiting on a separate ingestion step.
|
||||
|
||||
## Where it is
|
||||
|
||||
Running in production, feeding the other projects on this list.
|
||||
MD,
|
||||
],
|
||||
[
|
||||
'slug' => 'gabii',
|
||||
'title' => 'Gabii',
|
||||
'kind' => 'SaaS',
|
||||
'period_label' => 'In development',
|
||||
'started_on' => '2025-06-01',
|
||||
'ended_on' => null,
|
||||
'status' => 'building',
|
||||
'summary' => 'An AI-powered relationship platform using intelligent profiling, compatibility analysis, and autonomous matchmaking.',
|
||||
'lede' => 'Matching is a ranking problem that most platforms solve with engagement metrics rather than compatibility.',
|
||||
'role' => 'Founder — product and engineering',
|
||||
'categories' => ['SaaS', 'Agentic'],
|
||||
'stack' => ['LLM', 'Embeddings', 'PHP'],
|
||||
'skills' => [
|
||||
['group' => 'Engineering', 'items' => ['PHP', 'Embedding pipelines']],
|
||||
['group' => 'AI', 'items' => ['Intelligent profiling', 'Compatibility modelling', 'Autonomous matching']],
|
||||
],
|
||||
'metrics' => [],
|
||||
'links' => [],
|
||||
'body' => <<<'MD'
|
||||
## The idea
|
||||
|
||||
Most matching platforms optimise for engagement, because engagement is what
|
||||
they are measured on. That produces a product that works best when it does not
|
||||
quite work.
|
||||
|
||||
Gabii treats compatibility as the objective rather than time-on-app.
|
||||
|
||||
## Approach
|
||||
|
||||
Intelligent profiling built from how people actually describe themselves rather
|
||||
than from checkbox attributes, compatibility analysis over embeddings, and
|
||||
autonomous matchmaking that improves as it observes outcomes.
|
||||
|
||||
## Where it is
|
||||
|
||||
In development.
|
||||
MD,
|
||||
],
|
||||
];
|
||||
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO cja_projects
|
||||
(slug, title, kind, period_label, started_on, ended_on, status, summary, lede,
|
||||
body, role, categories, stack, skills, metrics, gallery, links, published, published_at, sort_order)
|
||||
VALUES
|
||||
(:slug, :title, :kind, :period_label, :started_on, :ended_on, :status, :summary, :lede,
|
||||
:body, :role, :categories, :stack, :skills, :metrics, :gallery, :links, 1, NOW(), :sort_order)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title), kind = VALUES(kind), period_label = VALUES(period_label),
|
||||
started_on = VALUES(started_on), ended_on = VALUES(ended_on), status = VALUES(status),
|
||||
summary = VALUES(summary), lede = VALUES(lede), body = VALUES(body), role = VALUES(role),
|
||||
categories = VALUES(categories), stack = VALUES(stack), skills = VALUES(skills), metrics = VALUES(metrics), gallery = VALUES(gallery),
|
||||
links = VALUES(links), sort_order = VALUES(sort_order)
|
||||
SQL;
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$json = static fn($v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
foreach ($projects as $i => $p) {
|
||||
$stmt->execute([
|
||||
':slug' => $p['slug'],
|
||||
':title' => $p['title'],
|
||||
':kind' => $p['kind'],
|
||||
':period_label' => $p['period_label'],
|
||||
':started_on' => $p['started_on'],
|
||||
':ended_on' => $p['ended_on'],
|
||||
':status' => $p['status'],
|
||||
':summary' => $p['summary'],
|
||||
':lede' => $p['lede'],
|
||||
':body' => $p['body'],
|
||||
':role' => $p['role'],
|
||||
':categories' => $json($p['categories']),
|
||||
':stack' => $json($p['stack']),
|
||||
':skills' => $json($p['skills'] ?? []),
|
||||
':metrics' => $json($p['metrics']),
|
||||
':gallery' => $json($p['gallery'] ?? []),
|
||||
':links' => $json($p['links']),
|
||||
':sort_order' => $i * 10,
|
||||
]);
|
||||
printf(" %-30s %s\n", $p['slug'], 'ok');
|
||||
}
|
||||
|
||||
$n = $pdo->query('SELECT COUNT(*) FROM cja_projects')->fetchColumn();
|
||||
echo "\n {$n} projects in cja_projects\n";
|
||||
|
|
@ -3,6 +3,13 @@
|
|||
-- every table uses CREATE TABLE IF NOT EXISTS, so it composes with the existing migrations.
|
||||
-- Generated 2026-07-11.
|
||||
|
||||
-- NOTE: this snapshot declares foreign keys before their referenced tables
|
||||
-- exist (mde_comments -> sp_users appears ~1100 lines earlier than sp_users).
|
||||
-- The export was generated without mysqldump's usual FK guard, so add it here.
|
||||
-- Constraints are still validated on the next write; this only defers checking
|
||||
-- during table creation, exactly as install/dump.sql already does.
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
|
|
@ -1313,3 +1320,4 @@ CREATE TABLE IF NOT EXISTS `sp_zipcode` (
|
|||
PRIMARY KEY (`id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
|
|
|
|||
67
api/db/migrations/004_create_cja_projects.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
-- Projects — the source of truth for /projects and /projects/{slug}.
|
||||
--
|
||||
-- Two shapes live in one row: the short form the index needs (title, period,
|
||||
-- summary, categories) and the long form a case-study page needs (lede, body,
|
||||
-- metrics, links). Keeping them together avoids a join on every page render
|
||||
-- and keeps editing in one place.
|
||||
--
|
||||
-- JSON is used for the small repeating lists — categories, stack, metrics,
|
||||
-- links. At this scale that is the right trade: a normalised category table
|
||||
-- would add two tables and a join to support a filter over a handful of rows.
|
||||
-- MariaDB can still query inside them via JSON_CONTAINS if that is ever needed.
|
||||
-- If projects grow past a few dozen, or categories need their own descriptions
|
||||
-- and pages, promote `categories` to cja_project_categories + a map table.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cja_projects` (
|
||||
`project_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- identity
|
||||
`slug` varchar(120) NOT NULL,
|
||||
`title` varchar(160) NOT NULL,
|
||||
`kind` varchar(60) NOT NULL DEFAULT '', -- "SaaS", "SaaS / Media"
|
||||
|
||||
-- timeline. period_label is what renders; the dates are what sorts, so an
|
||||
-- ongoing project ("2020 — present") still orders correctly against a dated one.
|
||||
`period_label` varchar(60) NOT NULL DEFAULT '',
|
||||
`started_on` date DEFAULT NULL,
|
||||
`ended_on` date DEFAULT NULL, -- NULL = ongoing
|
||||
`status` enum('live','building','archived','concept')
|
||||
NOT NULL DEFAULT 'live',
|
||||
|
||||
-- copy
|
||||
`summary` varchar(500) NOT NULL DEFAULT '', -- index row
|
||||
`lede` text DEFAULT NULL, -- detail page opener
|
||||
`body` mediumtext DEFAULT NULL, -- markdown
|
||||
`role` varchar(200) NOT NULL DEFAULT '', -- "Solo — design, build, ops"
|
||||
|
||||
-- structured lists
|
||||
`categories` longtext DEFAULT NULL CHECK (json_valid(`categories`)),
|
||||
`stack` longtext DEFAULT NULL CHECK (json_valid(`stack`)),
|
||||
`metrics` longtext DEFAULT NULL CHECK (json_valid(`metrics`)),
|
||||
`links` longtext DEFAULT NULL CHECK (json_valid(`links`)),
|
||||
|
||||
-- media
|
||||
`cover_image` varchar(255) NOT NULL DEFAULT '',
|
||||
`cover_alt` varchar(255) NOT NULL DEFAULT '',
|
||||
|
||||
-- seo. Nullable so the page can fall back to title/summary rather than
|
||||
-- forcing an editor to duplicate them.
|
||||
`seo_title` varchar(180) DEFAULT NULL,
|
||||
`seo_description` varchar(320) DEFAULT NULL,
|
||||
|
||||
-- publishing
|
||||
`featured` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`sort_order` int(11) NOT NULL DEFAULT 0,
|
||||
`published` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`published_at` datetime DEFAULT NULL,
|
||||
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||
ON UPDATE current_timestamp(),
|
||||
|
||||
PRIMARY KEY (`project_id`),
|
||||
UNIQUE KEY `uq_cja_projects_slug` (`slug`),
|
||||
KEY `idx_cja_projects_listing` (`published`, `sort_order`, `started_on`),
|
||||
KEY `idx_cja_projects_status` (`status`),
|
||||
KEY `idx_cja_projects_featured` (`featured`, `sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
36
api/db/migrations/005_create_cja_contact.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Contact form submissions.
|
||||
--
|
||||
-- `subject` is stored as free text (a slug from a known list), not an ENUM.
|
||||
-- ENUMs need a migration every time an option is added; the list of enquiry
|
||||
-- types will change more often than the schema should. The controller
|
||||
-- validates against the allowed set, so bad values never reach here.
|
||||
--
|
||||
-- Anti-spam and audit columns live alongside the message: a honeypot outcome,
|
||||
-- the origin IP, and the user agent. These make it possible to review what got
|
||||
-- flagged without a separate log.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cja_contact` (
|
||||
`contact_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
|
||||
`name` varchar(160) NOT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`subject` varchar(60) NOT NULL DEFAULT 'other',
|
||||
`message` text NOT NULL,
|
||||
|
||||
-- lifecycle so the inbox is workable, not just a dump
|
||||
`status` enum('new','read','replied','spam','archived')
|
||||
NOT NULL DEFAULT 'new',
|
||||
|
||||
-- audit / anti-spam
|
||||
`ip` varchar(45) DEFAULT NULL, -- v4 or v6
|
||||
`user_agent` varchar(255) DEFAULT NULL,
|
||||
`referrer` varchar(255) DEFAULT NULL,
|
||||
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||
ON UPDATE current_timestamp(),
|
||||
|
||||
PRIMARY KEY (`contact_id`),
|
||||
KEY `idx_cja_contact_status` (`status`, `created_at`),
|
||||
KEY `idx_cja_contact_email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
19
api/db/migrations/006_projects_gallery_skills.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Richer project detail pages: a photo gallery and a grouped skills breakdown.
|
||||
--
|
||||
-- Both are JSON, same reasoning as the categories/metrics columns already on
|
||||
-- this table: small, per-row, repeating lists that never need to be queried
|
||||
-- across projects. A gallery image belongs to exactly one project; normalising
|
||||
-- it into cja_project_images would add a table and a join to store what is
|
||||
-- effectively an ordered list.
|
||||
--
|
||||
-- gallery : [ { "src": "/projects/x/1.jpg", "alt": "...", "caption": "..." }, ... ]
|
||||
-- skills : [ { "group": "Engineering", "items": ["API design", "..."] }, ... ]
|
||||
--
|
||||
-- `links` already exists and is JSON — no change needed. It now carries more
|
||||
-- keys: { "live": "...", "instagram": "...", "github": "..." }.
|
||||
|
||||
ALTER TABLE `cja_projects`
|
||||
ADD COLUMN `gallery` longtext DEFAULT NULL
|
||||
CHECK (json_valid(`gallery`)) AFTER `metrics`,
|
||||
ADD COLUMN `skills` longtext DEFAULT NULL
|
||||
CHECK (json_valid(`skills`)) AFTER `stack`;
|
||||
26
api/db/migrations/007_create_cja_changelog.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Public, human-friendly changelog for the site itself.
|
||||
--
|
||||
-- Fits the "agentic website" thesis: the site documents its own evolution, and
|
||||
-- an agent can append a row here as part of shipping a change. Same JSON-free,
|
||||
-- flat shape the rest of the cja_* tables use — this is a log, not a graph.
|
||||
--
|
||||
-- `type` is a small closed set, so an ENUM is correct here (unlike the contact
|
||||
-- subject, which grows). `entry_at` is the event time shown to readers; the row
|
||||
-- timestamps are for bookkeeping.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cja_changelog` (
|
||||
`changelog_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
|
||||
`type` enum('added','updated','fixed','bug','removed','note')
|
||||
NOT NULL DEFAULT 'added',
|
||||
`summary` varchar(255) NOT NULL,
|
||||
`detail` varchar(500) DEFAULT NULL,
|
||||
|
||||
`entry_at` datetime NOT NULL, -- when the change happened
|
||||
`published` tinyint(1) NOT NULL DEFAULT 1,
|
||||
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
|
||||
PRIMARY KEY (`changelog_id`),
|
||||
KEY `idx_cja_changelog_feed` (`published`, `entry_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
113
api/public/controllers/contact.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Controllers\PublicController;
|
||||
|
||||
/**
|
||||
* Contact form endpoint.
|
||||
*
|
||||
* POST /api/contact/submit — accept a message, store it in cja_contact
|
||||
*
|
||||
* The router does `new Contact(...)`, so this is a GLOBAL class in
|
||||
* public/controllers/ (not namespaced), matching account.php / health.php.
|
||||
* The base class it extends is the namespaced one autoloaded by composer.
|
||||
*
|
||||
* Public, so it guards origin and rate-limits. Validation is server-side and
|
||||
* authoritative; the browser's `required` attributes are a convenience only.
|
||||
*/
|
||||
class Contact extends PublicController
|
||||
{
|
||||
/** Subjects the form offers. The slug is stored; the label is display-only. */
|
||||
private const SUBJECTS = [
|
||||
'automation' => 'Automation',
|
||||
'website-design' => 'Website Design',
|
||||
'digital-marketing' => 'Digital Marketing',
|
||||
'fractional-cto' => 'Fractional CTO',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
// 5 submissions per 10 minutes per IP — generous for a human, useless
|
||||
// for a script.
|
||||
$this->guardPublic('contact_submit', 5, 600);
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
$this->json(null, 405, ['code' => 'method_not_allowed', 'message' => 'POST only']);
|
||||
}
|
||||
|
||||
$in = $this->readBody();
|
||||
|
||||
// Honeypot: a field hidden from humans. A bot fills everything, so any
|
||||
// value here is spam. Answer 200 so it cannot tell it was caught, but
|
||||
// write nothing.
|
||||
if (trim((string) ($in['company'] ?? '')) !== '') {
|
||||
$this->json(['received' => true]);
|
||||
}
|
||||
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$subject = trim((string) ($in['subject'] ?? 'other'));
|
||||
$message = trim((string) ($in['message'] ?? ''));
|
||||
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 160) {
|
||||
$errors['name'] = 'Please enter your name.';
|
||||
}
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
|
||||
$errors['email'] = 'Please enter a valid email address.';
|
||||
}
|
||||
if (!isset(self::SUBJECTS[$subject])) {
|
||||
// Unknown subject is coerced, not rejected — never trust the
|
||||
// client's list, but do not fail a real person over it either.
|
||||
$subject = 'other';
|
||||
}
|
||||
if (mb_strlen($message) < 10) {
|
||||
$errors['message'] = 'Please add a little more detail (10 characters or more).';
|
||||
}
|
||||
if (mb_strlen($message) > 5000) {
|
||||
$errors['message'] = 'That message is a bit long — please keep it under 5000 characters.';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
$this->json(null, 422, [
|
||||
'code' => 'validation_failed',
|
||||
'message' => 'Please check the highlighted fields.',
|
||||
'fields' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
\Db::insert('cja_contact', [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'status' => 'new',
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
'referrer' => mb_substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 255),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// TODO: notify. A mailer helper exists (app/Helpers/Mailer.php, Brevo);
|
||||
// wire it once a from-address and API key are configured. The row is
|
||||
// saved regardless, so a mail outage never loses a lead.
|
||||
|
||||
$this->json([
|
||||
'received' => true,
|
||||
'message' => "Thanks — I'll get back to you shortly.",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Read a JSON body, falling back to form-encoded POST. */
|
||||
private function readBody(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw !== '' && $raw !== false) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
return $_POST ?: [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,542 @@
|
|||
[]
|
||||
[
|
||||
{
|
||||
"id": "err_6a6204ee4c9044.13450162",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/contact\/submit",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 08:11:26"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6204e4189018.64313489",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/contact\/submit",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 08:11:16"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6204e411d7f1.68494780",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/contact\/submit",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 08:11:16"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6204e40b68c3.54833855",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/contact\/submit",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 08:11:16"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d83d740d58.03977982",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95\/api\/.env",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.148.10.200",
|
||||
"user_agent": "l9explore\/1.2.2",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 05:00:45"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d585ba82c5.93490924",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v2.0\/systeminfo",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:09"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d585ad8eb4.47467749",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v1\/cluster\/summary",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:09"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d583f013a6.04024915",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/status",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:07"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d58384e463.42090894",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v1\/info",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:07"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d583390081.28105900",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v2\/hoverfly\/version",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:07"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d58180e553.92715360",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v1\/version",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:05"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d5816e61e3.32538381",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v3\/meta",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:05"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d580826025.08194969",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v1\/check-version",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:04"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d57fa07a28.14708581",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v1.0\/environment",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:03"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d57ed790d0.28757436",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/vip\/i18n\/api\/v2\/translation\/products\/vRNIUI\/versions\/1",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:02"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d57dded5b3.26360675",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/version",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:49:01"
|
||||
},
|
||||
{
|
||||
"id": "err_6a61d57be8d4c8.44732492",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95.vultrusercontent.com\/api\/v2\/about",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.79.102.191",
|
||||
"user_agent": "Mozilla\/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit\/537.36 (KHTML",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 04:48:59"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6131dbc671d4.14718320",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95:443\/api\/.env",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "80.94.95.211",
|
||||
"user_agent": "Mozilla\/5.0 (X11; Linux x86_64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/61.0.3163.79 Safari\/537.36",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 17:10:51"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6130caea4be3.10187418",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/app.medellin.co\/api\/graphql",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "104.28.229.13",
|
||||
"user_agent": "Go-http-client\/2.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 17:06:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6130c94f7da9.47659370",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/app.medellin.co\/api\/.env",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "104.28.229.13",
|
||||
"user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; PerplexityBot\/1.0; +https:\/\/perplexity.ai\/perplexitybot",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 17:06:17"
|
||||
},
|
||||
{
|
||||
"id": "err_6a612f82634e08.62465813",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/app.medellin.co\/api\/.env",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "104.28.211.190",
|
||||
"user_agent": "Mozilla\/5.0 (compatible; GoogleOther; +http:\/\/www.google.com\/bot.html)",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 17:00:50"
|
||||
},
|
||||
{
|
||||
"id": "err_6a612f81369191.96179634",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/app.medellin.co\/api\/graphql",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "104.28.211.190",
|
||||
"user_agent": "Go-http-client\/2.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 17:00:49"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6113b87c9b70.54588185",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/207.246.79.95:443\/api\/admin\/token",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php",
|
||||
"line": 222,
|
||||
"fullerror": "Call to undefined method Admin::index()",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#3 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "45.144.28.70",
|
||||
"user_agent": "Mozilla\/5.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 15:02:16"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60c8cf7dfe21.13179858",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/www.carlosarias.co\/api\/.env",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "172.69.33.15",
|
||||
"user_agent": null,
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 09:42:39"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb222642f1.89719899",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/console",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:44:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb221ea3e6.68499605",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/vendor\/autoload.php",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:44:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb22156601.09261541",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/composer.json",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:44:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb220de8d8.86707709",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/config.php",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:44:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb22066702.98933056",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/install\/",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:44:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a60bb0d8f2a18.55698756",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/install\/",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php",
|
||||
"line": 19,
|
||||
"fullerror": "Undefined constant \"COMPANY\"",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}",
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-22 08:43:57"
|
||||
}
|
||||
]
|
||||
127
app/app/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.25.0",
|
||||
"motion": "^12.42.2",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
|
||||
"integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.42.2",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
|
||||
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
|
||||
"integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.42.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
|
||||
"integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
app/app/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.25.0",
|
||||
"motion": "^12.42.2",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ const siteConfig = JSON.parse(
|
|||
readFileSync(new URL("./src/config/site.json", import.meta.url)),
|
||||
);
|
||||
import mdx from "@astrojs/mdx";
|
||||
import react from "@astrojs/react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const site =
|
||||
|
|
@ -18,9 +19,19 @@ export default defineConfig({
|
|||
// For previews, PREVIEW_BASE prefixes asset paths (/_preview/<id>) so the
|
||||
// preview is self-contained and doesn't pull _astro/fonts from the live site.
|
||||
base: process.env.PREVIEW_BASE || undefined,
|
||||
integrations: [mdx()],
|
||||
integrations: [mdx(), react()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// shadcn / 21st.dev convention — paste components in unmodified.
|
||||
"@": new URL("./src", import.meta.url).pathname,
|
||||
// Those components import useTheme from "next-themes", which is a
|
||||
// Next.js package. Point it at a local shim that drives the existing
|
||||
// class-based theme instead. See src/lib/next-themes.ts.
|
||||
"next-themes": new URL("./src/lib/next-themes.ts", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
},
|
||||
|
|
|
|||
28
app/components.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"ui": "@/components/ui",
|
||||
"utils": "@/lib/utils",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@21st": {
|
||||
"url": "https://21st.dev/r/{name}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${TWENTYFIRST_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1297
app/package-lock.json
generated
|
|
@ -13,12 +13,26 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^6.0.3",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^6.4.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"lucide-react": "^1.25.0",
|
||||
"marked": "^18.0.7",
|
||||
"motion": "^12.42.2",
|
||||
"mysql2": "^3.23.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"prettier": "^3.8.4",
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
eXf4NRu-MxHC0Dis4sHu5pC3gDElq1Y9p9LB0CSdHS0.yunwhBDUlOIssanN_n7Md4A3OXu6B-DP-N3ietCHx8E
|
||||
74
app/public/404-server.html
Normal file
87
app/public/404/stick0.svg
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:49.4652;stroke-dasharray:none"
|
||||
id="rect6"
|
||||
width="117.49371"
|
||||
height="87.496231"
|
||||
x="47.844078"
|
||||
y="144.05669" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.1064;stroke-dasharray:none"
|
||||
id="rect6-7"
|
||||
width="38.825333"
|
||||
height="79.51181"
|
||||
x="64.32328"
|
||||
y="211.95177" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.3364;stroke-dasharray:none"
|
||||
id="rect6-7-1"
|
||||
width="39.486782"
|
||||
height="79.51181"
|
||||
x="107.95576"
|
||||
y="211.28079" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:24.1165;stroke-dasharray:none"
|
||||
id="path1-5"
|
||||
cx="103.14185"
|
||||
cy="61.487297"
|
||||
rx="48.863613"
|
||||
ry="46.117508" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:26.1173;stroke-dasharray:none"
|
||||
id="path1-5-1"
|
||||
cx="106.57589"
|
||||
cy="143.94983"
|
||||
rx="58.612301"
|
||||
ry="45.09108" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:0.222166"
|
||||
id="path3"
|
||||
cx="106.40551"
|
||||
cy="140.5488"
|
||||
rx="52.150394"
|
||||
ry="35.780315" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.324797"
|
||||
id="rect4"
|
||||
width="104.23229"
|
||||
height="88.866142"
|
||||
x="54.323616"
|
||||
y="137.15443" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="68.690704"
|
||||
y="222.75345" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5-8"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="112.44756"
|
||||
y="222.38229" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:21.265;stroke-dasharray:none"
|
||||
id="path1"
|
||||
cx="103.59921"
|
||||
cy="63.375587"
|
||||
rx="42.328346"
|
||||
ry="41.392914" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
142
app/public/404/stick1.svg
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="stick1.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#628eb3"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.1313833"
|
||||
inkscape:cx="425.58522"
|
||||
inkscape:cy="410.55936"
|
||||
inkscape:window-width="1600"
|
||||
inkscape:window-height="837"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:25.9731"
|
||||
id="rect1"
|
||||
width="36.154182"
|
||||
height="79.61409"
|
||||
x="-69.172096"
|
||||
y="67.599037"
|
||||
transform="matrix(0.65462631,-0.75595264,0.73817391,0.67461046,0,0)" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:26.0155"
|
||||
id="rect1-8"
|
||||
width="36.197006"
|
||||
height="79.780632"
|
||||
x="-210.93234"
|
||||
y="-16.463089"
|
||||
transform="matrix(-0.77879414,-0.6272796,0.65239518,-0.75787896,0,0)" />
|
||||
<g
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:49.4652;stroke-dasharray:none"
|
||||
id="rect6"
|
||||
width="117.49371"
|
||||
height="87.496231"
|
||||
x="47.844078"
|
||||
y="144.05669" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.1064;stroke-dasharray:none"
|
||||
id="rect6-7"
|
||||
width="38.825333"
|
||||
height="79.51181"
|
||||
x="64.32328"
|
||||
y="211.95177" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.3364;stroke-dasharray:none"
|
||||
id="rect6-7-1"
|
||||
width="39.486782"
|
||||
height="79.51181"
|
||||
x="107.95576"
|
||||
y="211.28079" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:24.1165;stroke-dasharray:none"
|
||||
id="path1-5"
|
||||
cx="103.14185"
|
||||
cy="61.487297"
|
||||
rx="48.863613"
|
||||
ry="46.117508" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:26.1173;stroke-dasharray:none"
|
||||
id="path1-5-1"
|
||||
cx="106.57589"
|
||||
cy="143.94983"
|
||||
rx="58.612301"
|
||||
ry="45.09108" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:0.222166"
|
||||
id="path3"
|
||||
cx="106.40551"
|
||||
cy="140.5488"
|
||||
rx="52.150394"
|
||||
ry="35.780315" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.324797"
|
||||
id="rect4"
|
||||
width="104.23229"
|
||||
height="88.866142"
|
||||
x="54.323616"
|
||||
y="137.15443" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="68.690704"
|
||||
y="222.75345" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5-8"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="112.44756"
|
||||
y="222.38229" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:21.265;stroke-dasharray:none"
|
||||
id="path1"
|
||||
cx="103.59921"
|
||||
cy="63.375587"
|
||||
rx="42.328346"
|
||||
ry="41.392914" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:20.4725"
|
||||
id="rect1-1"
|
||||
width="28.489801"
|
||||
height="62.770103"
|
||||
x="-65.804031"
|
||||
y="72.406326"
|
||||
transform="matrix(0.6563428,-0.75446281,0.73971412,0.67292126,0,0)" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:21.2878"
|
||||
id="rect1-1-7"
|
||||
width="29.613789"
|
||||
height="65.293243"
|
||||
x="-207.31395"
|
||||
y="-1.9242077"
|
||||
transform="matrix(-0.76525188,-0.64373097,0.6559555,-0.75479956,0,0)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
144
app/public/404/stick2.svg
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="stick2.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#628eb3"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.40000439"
|
||||
inkscape:cx="323.74644"
|
||||
inkscape:cy="581.24361"
|
||||
inkscape:window-width="1600"
|
||||
inkscape:window-height="837"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:25.9699"
|
||||
id="rect1"
|
||||
width="36.14613"
|
||||
height="79.612419"
|
||||
x="-159.57356"
|
||||
y="-1.0789951"
|
||||
transform="matrix(-0.01912468,-0.99981711,0.99999191,-0.00402151,0,0)" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:26.0146"
|
||||
id="rect1-8"
|
||||
width="36.194088"
|
||||
height="79.781059"
|
||||
x="-158.16452"
|
||||
y="124.12318"
|
||||
transform="matrix(-0.03371912,-0.99943135,0.99999412,-0.00342918,0,0)"
|
||||
inkscape:transform-center-x="-0.82681383"
|
||||
inkscape:transform-center-y="-0.82681383" />
|
||||
<g
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:49.4652;stroke-dasharray:none"
|
||||
id="rect6"
|
||||
width="117.49371"
|
||||
height="87.496231"
|
||||
x="47.844078"
|
||||
y="144.05669" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.1064;stroke-dasharray:none"
|
||||
id="rect6-7"
|
||||
width="38.825333"
|
||||
height="79.51181"
|
||||
x="64.32328"
|
||||
y="211.95177" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.3364;stroke-dasharray:none"
|
||||
id="rect6-7-1"
|
||||
width="39.486782"
|
||||
height="79.51181"
|
||||
x="107.95576"
|
||||
y="211.28079" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:24.1165;stroke-dasharray:none"
|
||||
id="path1-5"
|
||||
cx="103.14185"
|
||||
cy="61.487297"
|
||||
rx="48.863613"
|
||||
ry="46.117508" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:26.1173;stroke-dasharray:none"
|
||||
id="path1-5-1"
|
||||
cx="106.57589"
|
||||
cy="143.94983"
|
||||
rx="58.612301"
|
||||
ry="45.09108" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:0.222166"
|
||||
id="path3"
|
||||
cx="106.40551"
|
||||
cy="140.5488"
|
||||
rx="52.150394"
|
||||
ry="35.780315" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.324797"
|
||||
id="rect4"
|
||||
width="104.23229"
|
||||
height="88.866142"
|
||||
x="54.323616"
|
||||
y="137.15443" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="68.690704"
|
||||
y="222.75345" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5-8"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="112.44756"
|
||||
y="222.38229" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:21.265;stroke-dasharray:none"
|
||||
id="path1"
|
||||
cx="103.59921"
|
||||
cy="63.375587"
|
||||
rx="42.328346"
|
||||
ry="41.392914" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:20.4725"
|
||||
id="rect1-1"
|
||||
width="28.489801"
|
||||
height="62.770103"
|
||||
x="-154.99321"
|
||||
y="4.9284301"
|
||||
transform="matrix(-0.02046436,-0.99979058,0.99999851,0.00172405,0,0)" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:21.2888"
|
||||
id="rect1-1-7"
|
||||
width="29.617867"
|
||||
height="65.290321"
|
||||
x="-155.70695"
|
||||
y="135.51163"
|
||||
transform="matrix(-0.0275328,-0.9996209,0.99997918,-0.00645334,0,0)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
166
app/public/404/stick3.svg
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="stick3.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#3742ab"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.1313833"
|
||||
inkscape:cx="406.14"
|
||||
inkscape:cy="481.26927"
|
||||
inkscape:window-width="1600"
|
||||
inkscape:window-height="837"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 148.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="210 : 148.5 : 1"
|
||||
inkscape:persp3d-origin="105 : 99 : 1"
|
||||
id="perspective5" />
|
||||
</defs>
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:26.109"
|
||||
id="rect2-8"
|
||||
width="51.448818"
|
||||
height="30.869291"
|
||||
x="126.80034"
|
||||
y="132.78999"
|
||||
ry="1.1024746" />
|
||||
<g
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:49.4652;stroke-dasharray:none"
|
||||
id="rect6"
|
||||
width="117.49371"
|
||||
height="87.496231"
|
||||
x="47.844078"
|
||||
y="144.05669" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.1064;stroke-dasharray:none"
|
||||
id="rect6-7"
|
||||
width="38.825333"
|
||||
height="79.51181"
|
||||
x="64.32328"
|
||||
y="211.95177" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:27.3364;stroke-dasharray:none"
|
||||
id="rect6-7-1"
|
||||
width="39.486782"
|
||||
height="79.51181"
|
||||
x="107.95576"
|
||||
y="211.28079" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:24.1165;stroke-dasharray:none"
|
||||
id="path1-5"
|
||||
cx="103.14185"
|
||||
cy="61.487297"
|
||||
rx="48.863613"
|
||||
ry="46.117508" />
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke-width:26.1173;stroke-dasharray:none"
|
||||
id="path1-5-1"
|
||||
cx="106.57589"
|
||||
cy="143.94983"
|
||||
rx="58.612301"
|
||||
ry="45.09108" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:0.222166"
|
||||
id="path3"
|
||||
cx="106.40551"
|
||||
cy="140.5488"
|
||||
rx="52.150394"
|
||||
ry="35.780315" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.324797"
|
||||
id="rect4"
|
||||
width="104.23229"
|
||||
height="88.866142"
|
||||
x="54.323616"
|
||||
y="137.15443" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="68.690704"
|
||||
y="222.75345" />
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.278287"
|
||||
id="rect5-8"
|
||||
width="29.910353"
|
||||
height="65.400063"
|
||||
x="112.44756"
|
||||
y="222.38229" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke-width:21.265;stroke-dasharray:none"
|
||||
id="path1"
|
||||
cx="103.59921"
|
||||
cy="63.375587"
|
||||
rx="42.328346"
|
||||
ry="41.392914" />
|
||||
</g>
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:21.265"
|
||||
id="rect2"
|
||||
width="40.223621"
|
||||
height="26.192125"
|
||||
x="132.36378"
|
||||
y="134.70236"
|
||||
ry="0.93543297" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke-width:14.1248"
|
||||
id="rect11"
|
||||
width="10.502132"
|
||||
height="5.881712"
|
||||
x="187.91344"
|
||||
y="127.77171"
|
||||
ry="0.58817118" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke-width:16.8997"
|
||||
id="rect12"
|
||||
width="5.6126008"
|
||||
height="19.644089"
|
||||
x="193.16693"
|
||||
y="127.68661"
|
||||
ry="0.93543297" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke-width:17.1056"
|
||||
id="rect13"
|
||||
width="15.902362"
|
||||
height="5.1448817"
|
||||
x="181.94174"
|
||||
y="142.18584"
|
||||
ry="0.60528016" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke-width:23.1639"
|
||||
id="rect1"
|
||||
width="26.659843"
|
||||
height="37.183464"
|
||||
x="164.1685"
|
||||
y="116.69527"
|
||||
ry="1.109954" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
74
app/public/502.html
Normal file
BIN
app/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
8
app/public/brand/brush.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 20" preserveAspectRatio="none">
|
||||
<!-- Sumi brush stroke: blunt where the brush lands (left), tapering as
|
||||
it lifts (right). The two thin trailing strokes are kasure — the
|
||||
dry-brush breaks that appear as the bristles run out of ink. -->
|
||||
<path d="M3 8C70 4 150 3 230 4.5C300 5.8 355 8 395 11C398 11.6 398 12.6 394 13C350 14.4 295 14 225 13.2C150 12.4 70 13.5 5 17C1 17.4 0 9 3 8Z"/>
|
||||
<path d="M298 14.8C328 15.2 358 15.6 384 15.8" stroke="black" stroke-width="0.9" fill="none" opacity="0.5"/>
|
||||
<path d="M118 15.6C158 16.1 208 16.2 248 16" stroke="black" stroke-width="0.7" fill="none" opacity="0.35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
27
app/public/brand/carlos-arias-signature.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg width="2077" height="520" viewBox="0 0 2077 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_9_24)">
|
||||
<path d="M729.773 299.39C712.092 310.877 656.321 347.702 633.908 336.721C624.625 329.443 632.319 246.762 638.008 226.642C637.972 226.66 637.917 226.678 637.881 226.696C640.319 219.653 619.441 220.7 617.996 227.581C612.705 252.758 593.958 343.313 635.226 348.641C655.761 351.296 710.575 316.692 729.322 304.682C734.451 301.395 734.993 295.977 729.773 299.39Z" fill="black"/>
|
||||
<path d="M733.729 296.789C715.831 299.444 700.804 328.558 720.183 334.265C735.444 338.78 759.14 331.213 771.06 322.851C784.334 313.532 795.026 294.134 785.418 285.845C772.559 274.755 752.765 279.632 738.858 284.58C736.311 285.483 733.133 290.125 737.937 289.276C746.01 287.867 754.534 287.055 762.734 286.874C775.178 286.585 777.851 290.125 768.206 303.779C762.138 312.394 748.105 318.715 737.485 319.311C728.166 319.817 748.701 307.969 747.653 304.068C745.667 296.771 741.711 295.597 733.729 296.771V296.789Z" fill="black"/>
|
||||
<path d="M737.16 282.702C729.593 288.572 731.724 291.931 736.727 290.649C741.152 289.529 835.374 269.337 849.497 283.912C866.239 301.16 851.159 261.824 848.179 264.027C838.01 271.541 748.43 273.961 737.178 282.702H737.16Z" fill="black"/>
|
||||
<path d="M608.768 288.77C588.883 319.257 561.991 339.864 543.479 331.448C535.478 327.095 542.269 307.463 531.487 300.058C525.509 295.959 518.862 294.026 511.801 292.581C508.261 291.859 504.667 291.407 501.073 291.046C500.784 289.872 500.585 288.662 500.477 287.434C499.574 280.824 481.495 268.579 479.346 278.602C475.264 297.656 494.986 307.355 511.241 308.33C515.232 308.565 519.224 308.33 523.215 308.402C524.353 308.42 525.057 316.909 525.274 318.444C526.321 325.759 528.326 331.249 533.582 336.902C554.171 358.99 606.095 308.619 622.656 292.726C624.137 291.299 609.328 287.831 608.732 288.752L608.768 288.77Z" fill="black"/>
|
||||
<path d="M408.729 298.198C409.126 286.387 385.611 281.619 370.621 289.258C352.398 298.541 317.577 319.13 325.885 333.001C330.852 341.291 355.468 337.697 367.569 330.834C381.855 322.724 405.424 309.811 415.7 310.479C418.445 310.66 426.934 304.736 424.315 303.526C405.767 294.911 372.283 321.749 350.971 326.77C341.038 329.1 362.349 313.459 379.236 309.486C384.203 308.312 408.476 305.747 408.729 298.216V298.198Z" fill="black"/>
|
||||
<path d="M491.211 284.96C473.277 308.782 445.681 327.908 430.149 327.474C424.803 327.312 429.914 313.532 428.595 306.163C427.602 300.528 408.801 301.214 409.379 307.824C410.932 325.488 405.622 338.744 424.189 339.412C439.685 339.954 470.46 321.912 497.767 287.922C495.492 287.109 493.306 286.134 491.23 284.978L491.211 284.96Z" fill="black"/>
|
||||
<path d="M1256.87 262.691C1245.91 266.827 1256.62 278.765 1262.11 282.901C1273.16 291.227 1283.78 290.504 1296.64 290.306C1291.71 286.459 1286.8 282.594 1281.87 278.747C1281.09 295.308 1270.47 306.596 1282.95 321.496C1291.96 332.278 1303.59 337.263 1317.54 337.968C1333.72 338.762 1349.58 335.06 1364.4 328.883C1372.73 325.415 1380.59 320.846 1388.39 316.367C1394.11 313.08 1401.01 309.432 1407.86 309.432C1410.17 309.432 1404.68 299.173 1403.02 299.119C1393.97 298.758 1387.11 308.547 1382.46 312.322C1371.43 321.243 1318.19 346.998 1296.82 324.91C1287.99 315.771 1300.72 297.006 1301.9 287.109C1302.62 280.986 1291.93 275.713 1287.12 275.55C1282.55 275.388 1278.13 274.141 1273.76 272.913C1270.22 271.776 1270.62 272.263 1274.91 274.394C1274.06 272.372 1272.91 270.529 1271.46 268.85C1268.43 264.66 1262.22 260.686 1256.94 262.691H1256.87Z" fill="black"/>
|
||||
<path d="M1661.59 257.851C1645.37 264.118 1626.16 281.059 1626.23 299.86C1626.3 318.444 1651.37 319.618 1662.44 328.395C1662.38 325.993 1662.35 323.591 1662.29 321.189C1660.24 323.555 1657.62 324.874 1654.44 325.108C1653.45 330.147 1652.45 335.168 1651.46 340.207C1714.22 320.16 1778.57 304.555 1844.18 298.216C1919.71 290.92 1988.65 281.655 2069.83 294.243C2071.42 294.496 2079.13 293.177 2076.35 292.69C2000.79 279.018 1922.89 284.093 1847.02 292.635C1809.6 296.844 1772.34 302.19 1735.21 308.475C1707.41 313.188 1678.24 317.288 1651.4 326.048C1643.08 328.775 1633.47 344.939 1648.42 341.146C1661.45 337.841 1675.35 331.971 1682.97 320.322C1684.69 317.685 1685.16 315.374 1682.83 313.098C1675.42 305.874 1664.97 304.23 1656.7 298.343C1644.09 289.367 1658.27 272.624 1666.54 266.881C1674.56 261.318 1670.31 254.473 1661.54 257.869L1661.59 257.851Z" fill="black"/>
|
||||
<path d="M1554.96 282.81C1555.36 270.999 1531.83 266.231 1516.85 273.87C1498.63 283.154 1463.81 303.761 1472.12 317.613C1477.08 325.903 1501.7 322.309 1513.78 315.446C1528.07 307.337 1551.64 294.423 1561.91 295.074C1564.66 295.254 1573.13 286.35 1571.29 284.111C1564.46 275.821 1518.5 306.343 1497.19 311.364C1487.25 313.694 1508.56 298.054 1525.47 294.08C1530.43 292.906 1554.69 290.324 1554.96 282.81Z" fill="black"/>
|
||||
<path d="M1559.02 290.107C1554.83 333.109 1617.4 311.581 1637.91 301.503C1639.39 300.781 1641.71 297.765 1638.62 297.711C1617.5 297.331 1576.56 319.148 1571.96 285.104C1571.16 279.216 1559.44 285.881 1559.02 290.107Z" fill="black"/>
|
||||
<path d="M1508.17 281.293C1490.29 291.588 1461.26 328.558 1425.9 326.896C1393.21 325.343 1419.78 286.423 1413.71 284.147C1408.67 282.251 1399.57 281.275 1396.73 284.147C1392.31 288.662 1384.81 304.14 1390.14 318.245C1396.75 335.674 1413.78 337.588 1430.11 335.132C1452.27 331.791 1500.87 296.898 1510.59 285.393C1512.08 283.623 1508.83 280.896 1508.15 281.293H1508.17Z" fill="black"/>
|
||||
<path d="M1399.57 254.672C1407.1 251.999 1414.41 249.146 1421.6 245.606C1426.44 243.204 1435.04 237.749 1435.83 231.699C1435.83 231.049 1435.54 230.561 1435 230.218C1430.98 228.448 1425.39 230.363 1421.46 231.428C1413.96 233.451 1406.74 236.864 1399.53 239.718C1394.91 241.542 1387.2 246.057 1387.21 251.963C1387.23 258.031 1396.41 255.792 1399.57 254.672Z" fill="black"/>
|
||||
<path d="M866.925 252.396C850.707 258.663 831.49 275.604 831.563 294.405C831.635 312.99 856.703 314.164 867.774 322.941C867.72 320.539 867.684 318.137 867.63 315.735C865.571 318.101 862.952 319.419 859.773 319.654C858.78 324.693 857.787 329.714 856.811 334.753C919.572 314.724 1001.22 285.393 1081.18 293.358C1100.09 295.236 1085.28 289.89 1082.5 289.385C1006.93 275.713 883.613 311.816 856.775 320.593C848.449 323.32 838.841 339.485 853.795 335.692C866.817 332.387 880.724 326.517 888.345 314.868C890.061 312.231 890.53 309.919 888.201 307.644C880.796 300.438 870.339 298.776 862.067 292.888C849.461 283.912 863.638 267.17 871.91 261.427C879.929 255.864 875.685 249.019 866.907 252.415L866.925 252.396Z" fill="black"/>
|
||||
<path d="M981.159 467.173C987.299 433.219 1000.92 399.915 1013.29 367.876C1030.7 322.779 1049.9 278.331 1069.66 234.228C1088.37 192.472 1107.85 151.023 1128.41 110.133C1140.2 86.6547 1151.52 60.9545 1167.72 40.0584C1168.41 39.1734 1166.98 40.7447 1168.66 39.0831C1165.81 41.9006 1153.33 38.6316 1157.85 44.1581C1160.95 47.9689 1162.99 52.6285 1164.76 57.1617C1169.95 70.5446 1171.59 85.1737 1172.91 99.3512C1176.61 138.868 1175.11 178.872 1173.45 218.443C1171.81 257.634 1168.9 296.771 1168 335.981C1167.69 349.165 1166.89 362.801 1169.11 375.859C1170.43 383.643 1174.08 388.79 1177.4 392.835C1180.06 396.068 1189.52 390.144 1187.27 387.363C1185.84 385.629 1187.45 379.705 1187.46 377.665C1187.48 371.578 1187.92 365.456 1187.79 359.369C1187.43 341.923 1188.26 324.44 1189.05 307.03C1191.13 260.704 1193.14 214.289 1193.46 167.909C1193.73 129.603 1194.45 89.1109 1183.96 51.9061C1181.83 44.3749 1176.97 27.4882 1166.35 29.9806C1157.86 31.9673 1152.44 38.8664 1147.71 45.6572C1130.93 69.8583 1117.84 97.0214 1104.39 123.137C1083.36 163.99 1063.61 205.475 1044.52 247.249C1024.87 290.233 1006.17 333.651 989.25 377.791C977.131 409.415 964.651 441.96 959.377 475.589C958.113 483.626 979.768 475.029 981.195 467.155L981.159 467.173Z" fill="black"/>
|
||||
<path d="M1247.8 271.92C1247.8 271.92 1248.08 271.739 1247.93 271.739C1247.84 271.739 1247.66 271.92 1247.8 271.92Z" fill="black"/>
|
||||
<path d="M410.499 400.71C506.039 389.531 821.792 362.584 1065.48 363.252C1185.88 363.577 1274.05 361.844 1378.33 363.686C1463.99 365.203 1549.45 374.179 1635.04 376.346C1636.59 376.382 1641.6 372.626 1638.62 372.066C1595.8 363.921 1551.35 362.078 1507.91 359.568C1453.73 356.425 1399.44 354.71 1345.19 353.716C1222.05 351.477 1098.81 352.723 975.686 355.649C790.565 360.038 604.325 365.275 420.215 386.551C415.104 387.147 409.776 389.621 406.796 393.991C404.448 397.405 405.984 401.234 410.517 400.71H410.499Z" fill="black"/>
|
||||
<path d="M1245.04 328.251C1249.57 321.424 1254.18 314.579 1257.61 307.102C1261.48 298.686 1260.83 291.516 1258.93 282.811C1258.66 281.528 1256.17 282.883 1255.97 283.714C1252.3 299.535 1243.71 314.868 1235.34 328.648C1231.69 334.662 1242.96 331.357 1245.02 328.233L1245.04 328.251Z" fill="black"/>
|
||||
<path d="M259.422 216.763C281.383 192.399 300.04 163.394 313.025 133.558C326.318 102.981 340.351 67.6548 339.015 33.7009C338.021 8.30776 319.365 -3.35936 295.398 0.848748C264.876 6.21274 237.352 30.2694 215.101 50.2083C183.585 78.455 155.808 110.964 129.783 144.232C104.751 176.217 81.1638 209.701 61.0263 244.992C43.5256 275.658 28.7701 308.095 17.392 341.543C5.11078 377.646 -4.46133 417.903 2.22109 456.083C7.15162 484.258 25.0858 514.293 55.4275 519.079C84.5592 523.684 115.208 510.59 140.673 497.966C206.269 465.457 264.515 414.671 313.206 360.597C323.934 348.695 333.741 336.143 341.688 322.201C345.011 316.367 334.626 319.979 332.711 322.58C314.886 346.799 292.418 368.092 270.529 388.555C242.571 414.689 212.031 438.529 180.316 459.912C154.255 477.485 125.232 494.679 94.1855 501.416C83.909 503.637 72.2961 504.359 62.2725 500.531C48.3478 495.203 38.3422 481.82 32.003 468.888C15.4234 435.115 22.9907 392.474 32.3822 357.816C42.1169 321.893 57.1252 287.037 74.662 254.257C92.7045 220.519 114.594 188.715 137.567 158.192C163.43 123.841 191.55 90.9168 222.795 61.3336C240.765 44.3205 264.208 23.1536 286.783 17.7896C292.978 16.3086 299.769 17.013 305.946 21.9977C313.513 28.1021 313.928 39.3719 313.694 48.2216C312.881 79.4303 285.375 172.045 250.373 212.23C247.682 215.318 238.201 225.017 244.775 226.353C250.861 227.581 255.466 221.188 259.458 216.763H259.422Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9_24">
|
||||
<rect width="2076.97" height="520" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
BIN
app/public/carlos-arias-dark.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
app/public/carlos-arias-light.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
app/public/carlos-arias-resume.pdf
Normal file
BIN
app/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
app/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
5
app/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="12" fill="#d0342c"/>
|
||||
<text x="50" y="55" font-family="'Hiragino Mincho ProN','Yu Mincho','Songti SC',serif"
|
||||
font-size="66" fill="#ffffff" text-anchor="middle" dominant-baseline="central">印</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
BIN
app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
app/public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
app/public/portfolio/ginny-goldman.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
73
app/src/components/BrandMark.astro
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
/**
|
||||
* The signature wordmark, optionally writing itself on load.
|
||||
*
|
||||
* A signature is a brush stroke and a personal seal at once, which is why
|
||||
* it carries the sumi-e direction better than any lettering could. The
|
||||
* write-on is a left-to-right reveal — the direction of writing — and it
|
||||
* says "autonomous" by drawing itself without a hand on it.
|
||||
*
|
||||
* The SVG is a single-colour black wordmark, so it inverts for dark
|
||||
* grounds rather than shipping a second file.
|
||||
*/
|
||||
const {
|
||||
width = "148px",
|
||||
animate = false,
|
||||
invert = false,
|
||||
seal = true,
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<span class={`ca-mark inline-flex items-center gap-3 ${className}`}>
|
||||
<img
|
||||
src="/brand/carlos-arias-signature.svg"
|
||||
alt="Carlos Arias"
|
||||
width="2077"
|
||||
height="520"
|
||||
style={`width:${width};height:auto`}
|
||||
class={`ca-mark-img ${invert ? "ca-invert" : ""} ${animate ? "ca-write" : ""}`}
|
||||
/>
|
||||
{seal && <span class="ca-seal" aria-hidden="true">印</span>}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* Black artwork: inverted wherever it sits on an ink ground. */
|
||||
:global(.dark) .ca-mark-img,
|
||||
.ca-mark-img.ca-invert {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.ca-seal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--ca-seal);
|
||||
color: #fff;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ca-write {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation: ca-write-on var(--ca-dur-ink) cubic-bezier(0.31, 0.06, 0.2, 1) 0.25s forwards;
|
||||
}
|
||||
|
||||
@keyframes ca-write-on {
|
||||
to {
|
||||
clip-path: inset(0 -2% 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ca-write {
|
||||
clip-path: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
---
|
||||
import Icon from "./Icon.astro";
|
||||
import { SITE, categories } from "../lib/blog-data.js";
|
||||
import { SITE, authors } from "../lib/blog-data.js";
|
||||
|
||||
// Social profiles come from site.config.json -> author.sameAs, which is also
|
||||
// what feeds the Person JSON-LD. One list, not two.
|
||||
const socials = (authors[0]?.sameAs ?? []).map((href) => {
|
||||
const host = new URL(href).hostname.replace(/^www\./, "");
|
||||
const label = host.split(".")[0];
|
||||
return { href, label: label.charAt(0).toUpperCase() + label.slice(1) };
|
||||
});
|
||||
|
||||
const { flush = false } = Astro.props;
|
||||
---
|
||||
|
|
@ -12,19 +20,42 @@ const { flush = false } = Astro.props;
|
|||
<p class="mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||
{SITE.description}
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-3 text-muted-foreground">
|
||||
<a href="/rss.xml" aria-label="RSS" class="hover:text-foreground"><Icon name="rss" class="h-4 w-4" /></a>
|
||||
<a href="https://x.com/" aria-label={`${SITE.name} on X`} class="hover:text-foreground"><Icon name="twitter" class="h-4 w-4" /></a>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
{
|
||||
socials.map((s) => (
|
||||
<a
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ca-quiet font-mono text-xs tracking-[0.1em] text-muted-foreground uppercase hover:text-foreground"
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a
|
||||
href="/rss.xml"
|
||||
class="ca-quiet font-mono text-xs tracking-[0.1em] text-muted-foreground uppercase hover:text-foreground"
|
||||
>RSS</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Sections</div>
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Services</div>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
{
|
||||
categories.slice(0, 5).map((category) => (
|
||||
[
|
||||
{ label: "Automation", href: "/services#automation" },
|
||||
{ label: "Website Design", href: "/services#website-design" },
|
||||
{ label: "Digital Marketing", href: "/services#digital-marketing" },
|
||||
{ label: "For Law Firms", href: "/services" },
|
||||
].map((s) => (
|
||||
<li>
|
||||
<a href={`/categories/${category.slug}`} class="text-foreground/80 hover:text-foreground">
|
||||
{category.name}
|
||||
<a
|
||||
href={s.href}
|
||||
class="text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
|
|
@ -35,10 +66,10 @@ const { flush = false } = Astro.props;
|
|||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">The site</div>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
<li><a href="/about" class="hover:text-foreground">About</a></li>
|
||||
<li><a href="/faq" class="hover:text-foreground">FAQ</a></li>
|
||||
<li><a href="/projects" class="hover:text-foreground">Projects</a></li>
|
||||
<li><a href="/resume" class="hover:text-foreground">Resume</a></li>
|
||||
<li><a href="/contact" class="hover:text-foreground">Contact</a></li>
|
||||
<li><a href="/blog" class="hover:text-foreground">Archive</a></li>
|
||||
<li><a href="/rss.xml" class="hover:text-foreground">RSS feed</a></li>
|
||||
<li><a href="/changelog" class="hover:text-foreground">Changelog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
---
|
||||
import Icon from "./Icon.astro";
|
||||
import { ThemeToggle } from "@/components/ui/be-ui-theme-toggle";
|
||||
import { CurvedMenu } from "@/components/ui/curved-menu";
|
||||
import { SITE } from "../lib/blog-data.js";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Home" },
|
||||
{ to: "/blog", label: "Writing" },
|
||||
{ to: "/about", label: "About" },
|
||||
{ to: "/services", label: "Services" },
|
||||
{ to: "/contact", label: "Contact" },
|
||||
{ to: "/projects", label: "My Projects" },
|
||||
{ to: "/about", label: "About" },
|
||||
{ to: "/resume", label: "Resume" },
|
||||
{ to: "/contact", label: "Contact Me" },
|
||||
];
|
||||
|
||||
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
|
||||
|
|
@ -26,8 +28,15 @@ const iconClass = isHome
|
|||
|
||||
<header class={headerClass} data-home-header={isHome ? "true" : undefined} data-scrolled="false">
|
||||
<div class="relative mx-auto flex h-16 max-w-6xl items-center justify-between px-5">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class={`font-serif text-xl font-semibold tracking-tight ${isHome ? "home-header-brand" : ""}`}>{SITE.name}</span>
|
||||
<a href="/" class="ca-header-brand flex items-center gap-2.5" aria-label={SITE.name}>
|
||||
<img
|
||||
src="/brand/carlos-arias-signature.svg"
|
||||
alt={SITE.name}
|
||||
width="2077"
|
||||
height="520"
|
||||
class="ca-header-logo h-6 w-auto sm:h-7"
|
||||
/>
|
||||
<span class="ca-header-seal" aria-hidden="true">印</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-8 md:flex" aria-label="Primary navigation">
|
||||
|
|
@ -38,7 +47,7 @@ const iconClass = isHome
|
|||
return (
|
||||
<a
|
||||
href={item.to}
|
||||
class={`text-sm ${active ? activeClass : inactiveClass}`}
|
||||
class={`ca-nav-link text-sm ${active ? activeClass : inactiveClass}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
|
|
@ -66,26 +75,23 @@ const iconClass = isHome
|
|||
>
|
||||
<Icon name="rss" class="h-4 w-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle
|
||||
aria-label="Toggle dark mode"
|
||||
class={iconClass}
|
||||
>
|
||||
<span data-theme-icon="moon"><Icon name="moon" class="h-4 w-4" /></span>
|
||||
<span data-theme-icon="sun" hidden><Icon name="sun" class="h-4 w-4" /></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-menu-toggle
|
||||
aria-label="Menu"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded="false"
|
||||
class={`${iconClass} md:hidden`}
|
||||
>
|
||||
<span data-menu-icon="menu"><Icon name="menu" class="h-4 w-4" /></span>
|
||||
<span data-menu-icon="x" hidden><Icon name="x" class="h-4 w-4" /></span>
|
||||
</button>
|
||||
<!-- React island. Only this component ships JS; the rest of the page
|
||||
stays static. client:load so the icon is correct before paint. -->
|
||||
<ThemeToggle
|
||||
client:load
|
||||
variant="circle-blur"
|
||||
start="top-right"
|
||||
className={iconClass}
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
<!-- Full-page mobile menu. client:media so desktop ships none of
|
||||
this JS at all — the island only hydrates below the md breakpoint. -->
|
||||
<CurvedMenu
|
||||
client:media="(max-width: 767px)"
|
||||
items={nav}
|
||||
current={current}
|
||||
triggerClassName={`${iconClass} md:hidden`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,54 +107,20 @@ const iconClass = isHome
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div id="mobile-menu" data-menu-panel class="border-t border-border/60 bg-background text-foreground md:hidden" hidden>
|
||||
<nav class="mx-auto flex max-w-6xl flex-col gap-1 px-5 py-3" aria-label="Mobile navigation">
|
||||
{
|
||||
nav.map((item) => {
|
||||
const exact = item.to === "/";
|
||||
const active = exact ? current === "/" : current.startsWith(item.to);
|
||||
return (
|
||||
<a
|
||||
href={item.to}
|
||||
class={`rounded-md px-2 py-2 text-sm hover:bg-muted hover:text-foreground ${active ? "text-foreground" : "text-muted-foreground"}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const searchToggle = document.querySelector("[data-search-toggle]");
|
||||
const searchPanel = document.querySelector("[data-search-panel]");
|
||||
const searchInput = document.querySelector("[data-search-input]");
|
||||
const menuToggle = document.querySelector("[data-menu-toggle]");
|
||||
const menuPanel = document.querySelector("[data-menu-panel]");
|
||||
const menuIcon = document.querySelector('[data-menu-icon="menu"]');
|
||||
const closeIcon = document.querySelector('[data-menu-icon="x"]');
|
||||
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||
const moonIcon = document.querySelector('[data-theme-icon="moon"]');
|
||||
const sunIcon = document.querySelector('[data-theme-icon="sun"]');
|
||||
const homeHeader = document.querySelector("[data-home-header]");
|
||||
|
||||
const setPanelOpen = () => {
|
||||
if (!homeHeader) return;
|
||||
const open = !searchPanel.hidden || !menuPanel.hidden;
|
||||
const open = !searchPanel.hidden;
|
||||
homeHeader.dataset.panelOpen = String(open);
|
||||
};
|
||||
|
||||
const setMenu = (open) => {
|
||||
menuPanel.hidden = !open;
|
||||
menuIcon.hidden = open;
|
||||
closeIcon.hidden = !open;
|
||||
menuToggle?.setAttribute("aria-expanded", String(open));
|
||||
setPanelOpen();
|
||||
};
|
||||
|
||||
const setSearch = (open) => {
|
||||
searchPanel.hidden = !open;
|
||||
searchToggle?.setAttribute("aria-expanded", String(open));
|
||||
|
|
@ -156,14 +128,6 @@ const iconClass = isHome
|
|||
setPanelOpen();
|
||||
};
|
||||
|
||||
const setThemeIcon = () => {
|
||||
const dark = document.documentElement.classList.contains("dark");
|
||||
moonIcon.hidden = dark;
|
||||
sunIcon.hidden = !dark;
|
||||
};
|
||||
|
||||
setThemeIcon();
|
||||
|
||||
const setHeaderScrolled = () => {
|
||||
if (!homeHeader) return;
|
||||
homeHeader.dataset.scrolled = window.scrollY > 8 ? "true" : "false";
|
||||
|
|
@ -175,8 +139,6 @@ const iconClass = isHome
|
|||
setSearch(searchPanel.hidden);
|
||||
});
|
||||
|
||||
menuToggle?.addEventListener("click", () => setMenu(menuPanel.hidden));
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
|
|
@ -185,16 +147,6 @@ const iconClass = isHome
|
|||
searchToggle?.focus();
|
||||
}
|
||||
|
||||
if (!menuPanel.hidden) {
|
||||
setMenu(false);
|
||||
menuToggle?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
themeToggle?.addEventListener("click", () => {
|
||||
const next = !document.documentElement.classList.contains("dark");
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
setThemeIcon();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
47
app/src/components/IndexRow.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
/**
|
||||
* One row of a typographic index.
|
||||
*
|
||||
* Used by the work list, the projects page, and anywhere else a list of
|
||||
* linked things needs the house treatment. Cards are the default on most
|
||||
* sites and they are wrong here — this is a quiet, scannable index.
|
||||
*
|
||||
* Styling lives in styles.css under @layer components (.ca-entry / .ca-tick)
|
||||
* so a change to hover behaviour is one edit, not one per page.
|
||||
*/
|
||||
const { href, title, meta, summary, tags = [], first = false } = Astro.props;
|
||||
---
|
||||
|
||||
<a href={href} class:list={["ca-entry", first && "border-t border-border"]}>
|
||||
<span class="ca-tick" aria-hidden="true">—</span>
|
||||
|
||||
<span class="font-serif text-2xl leading-snug tracking-tight">{title}</span>
|
||||
|
||||
{
|
||||
meta && (
|
||||
<span class="font-mono text-xs tracking-wide whitespace-nowrap text-[var(--ca-ink-4)] tabular-nums">
|
||||
{meta}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
summary && (
|
||||
<span class="col-start-2 mt-2 max-w-[56ch] text-sm text-muted-foreground">
|
||||
{summary}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<span class="col-start-2 mt-3 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span class="rounded-[var(--radius)] border border-border px-2 py-0.5 font-mono text-[0.7rem] tracking-wide text-[var(--ca-ink-4)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
142
app/src/components/InkWash.astro
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
/**
|
||||
* Ink diffusing into wet paper.
|
||||
*
|
||||
* Follows the pointer, and keeps drifting on its own when nobody is
|
||||
* touching it — the system runs whether or not you are watching. That is
|
||||
* the "agentic" idea expressed in ink rather than neon: no particles, no
|
||||
* constellation lines, no glow.
|
||||
*
|
||||
* Reads --ca-ink from its own parent rather than :root, because it sits
|
||||
* inside .ca-ink-surface where that token is the light value. Reading
|
||||
* :root would paint near-black onto near-black and show nothing.
|
||||
*/
|
||||
---
|
||||
|
||||
<canvas class="ca-wash" aria-hidden="true"></canvas>
|
||||
|
||||
<style>
|
||||
.ca-wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 1.6s linear;
|
||||
}
|
||||
|
||||
.ca-wash[data-on="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ca-wash {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const canvas = document.querySelector(".ca-wash");
|
||||
if (!(canvas instanceof HTMLCanvasElement)) return;
|
||||
const host = canvas.parentElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx || !host) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let ink = [237, 236, 232];
|
||||
const blooms = [];
|
||||
const last = { x: 0, y: 0 };
|
||||
|
||||
const readInk = () => {
|
||||
const raw = getComputedStyle(host).getPropertyValue("--ca-ink").trim();
|
||||
const m = raw.match(/^#?([0-9a-f]{6})$/i);
|
||||
if (!m) return;
|
||||
const n = parseInt(m[1], 16);
|
||||
ink = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
|
||||
const size = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
w = r.width;
|
||||
h = r.height;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
const bloom = (x, y, r, a) => {
|
||||
blooms.push({ x, y, r, a, life: 1, grow: 0.4 + Math.random() * 0.7 });
|
||||
if (blooms.length > 90) blooms.shift();
|
||||
};
|
||||
|
||||
readInk();
|
||||
size();
|
||||
window.addEventListener("resize", size);
|
||||
|
||||
host.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - r.left;
|
||||
const y = event.clientY - r.top;
|
||||
if (Math.hypot(x - last.x, y - last.y) > 26) {
|
||||
bloom(x, y, 26 + Math.random() * 46, 0.05);
|
||||
last.x = x;
|
||||
last.y = y;
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
const frame = () => {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ambient drift — the page is never entirely still
|
||||
if (Math.random() < 0.012) {
|
||||
bloom(
|
||||
w * (0.1 + Math.random() * 0.8),
|
||||
h * (0.15 + Math.random() * 0.7),
|
||||
60 + Math.random() * 120,
|
||||
0.022,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = blooms.length - 1; i >= 0; i--) {
|
||||
const b = blooms[i];
|
||||
b.life -= 0.0034;
|
||||
b.r += b.grow;
|
||||
if (b.life <= 0) {
|
||||
blooms.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const alpha = b.a * b.life;
|
||||
const [r, g, bl] = ink;
|
||||
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
|
||||
grad.addColorStop(0, `rgba(${r},${g},${bl},${alpha})`);
|
||||
grad.addColorStop(0.55, `rgba(${r},${g},${bl},${alpha * 0.35})`);
|
||||
grad.addColorStop(1, `rgba(${r},${g},${bl},0)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
setTimeout(() => canvas.setAttribute("data-on", "true"), 400);
|
||||
|
||||
new MutationObserver(readInk).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
23
app/src/components/SectionHead.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
/**
|
||||
* Section heading with optional right-aligned meta.
|
||||
*
|
||||
* `brush` puts the vermillion sumi stroke under the title. Use it on
|
||||
* emphasis only — the brand guide is explicit that brushing every heading
|
||||
* is the same as brushing none.
|
||||
*/
|
||||
const { title, meta, brush = false, id } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="mb-12 flex flex-wrap items-baseline gap-6" id={id}>
|
||||
<h2 class="font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
{brush ? <span class="ca-brushed">{title}</span> : title}
|
||||
</h2>
|
||||
{
|
||||
meta && (
|
||||
<span class="ml-auto font-mono text-xs tracking-[0.1em] text-[var(--ca-ink-4)] uppercase">
|
||||
{meta}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
36
app/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
248
app/src/components/ui/be-ui-theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
|
||||
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
|
||||
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
|
||||
|
||||
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
|
||||
|
||||
export const SPRING_PRESS = {
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
mass: 0.6,
|
||||
} as const;
|
||||
|
||||
export const SPRING_SWAP = {
|
||||
type: "spring",
|
||||
stiffness: 460,
|
||||
damping: 30,
|
||||
mass: 0.55,
|
||||
} as const;
|
||||
|
||||
export const SPRING_PANEL = {
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 40,
|
||||
mass: 0.5,
|
||||
} as const;
|
||||
|
||||
export const SPRING_LAYOUT = {
|
||||
type: "spring",
|
||||
stiffness: 360,
|
||||
damping: 32,
|
||||
mass: 0.6,
|
||||
} as const;
|
||||
|
||||
export const SPRING_MOUSE = {
|
||||
stiffness: 200,
|
||||
damping: 15,
|
||||
mass: 0.3,
|
||||
} as const;
|
||||
|
||||
export type ThemeVariant = "rectangle" | "circle" | "circle-blur";
|
||||
|
||||
export type RectStart =
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "center"
|
||||
| "bottom-up";
|
||||
|
||||
export interface ThemeToggleProps
|
||||
extends Omit<ComponentPropsWithoutRef<"button">, "children" | "onClick"> {
|
||||
variant?: ThemeVariant;
|
||||
start?: RectStart;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
interface ActionSwapIconProps {
|
||||
value: string;
|
||||
animation?: "blur";
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ActionSwapIcon({ value, className, children }: ActionSwapIconProps) {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={value}
|
||||
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.85, filter: "blur(8px)" }}
|
||||
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: "blur(8px)" }}
|
||||
transition={reduce ? { duration: 0 } : SPRING_SWAP}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
const VT_STYLE_ID = "beui-theme-toggle-vt";
|
||||
|
||||
const VT_CSS = `
|
||||
html[data-beui-vt="rect"]::view-transition-old(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
html[data-beui-vt="rect"]::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: beui-rect-reveal 400ms ease-out;
|
||||
}
|
||||
html[data-beui-vt="circle"]::view-transition-old(root),
|
||||
html[data-beui-vt="circle-blur"]::view-transition-old(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
html[data-beui-vt="circle"]::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: beui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
html[data-beui-vt="circle-blur"]::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: beui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@keyframes beui-rect-reveal {
|
||||
from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
@keyframes beui-circle-reveal {
|
||||
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); }
|
||||
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); }
|
||||
}
|
||||
@keyframes beui-circle-blur-reveal {
|
||||
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); }
|
||||
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); }
|
||||
}
|
||||
`;
|
||||
|
||||
const RECT_FROM: Record<RectStart, string> = {
|
||||
"top-left": "inset(0 100% 100% 0)",
|
||||
"top-right": "inset(0 0 100% 100%)",
|
||||
"bottom-left": "inset(100% 100% 0 0)",
|
||||
"bottom-right": "inset(100% 0 0 100%)",
|
||||
center: "inset(50% 50% 50% 50%)",
|
||||
"bottom-up": "inset(100% 0 0 0)",
|
||||
};
|
||||
|
||||
const CIRCLE_ORIGIN: Record<RectStart, string> = {
|
||||
"top-left": "0% 0%",
|
||||
"top-right": "100% 0%",
|
||||
"bottom-left": "0% 100%",
|
||||
"bottom-right": "100% 100%",
|
||||
center: "50% 50%",
|
||||
"bottom-up": "50% 100%",
|
||||
};
|
||||
|
||||
export function useThemeToggle({
|
||||
variant = "rectangle",
|
||||
start = "bottom-up",
|
||||
}: {
|
||||
variant?: ThemeVariant;
|
||||
start?: RectStart;
|
||||
} = {}) {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.getElementById(VT_STYLE_ID)) return;
|
||||
|
||||
const el = document.createElement("style");
|
||||
el.id = VT_STYLE_ID;
|
||||
el.textContent = VT_CSS;
|
||||
document.head.appendChild(el);
|
||||
}, []);
|
||||
|
||||
const isDark = mounted && resolvedTheme === "dark";
|
||||
|
||||
const toggle = () => {
|
||||
const next = isDark ? "light" : "dark";
|
||||
|
||||
if (reduce || !("startViewTransition" in document)) {
|
||||
setTheme(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
if (variant === "rectangle") {
|
||||
root.style.setProperty("--beui-vt-from", RECT_FROM[start]);
|
||||
root.dataset.beuiVt = "rect";
|
||||
} else {
|
||||
root.style.setProperty("--beui-vt-origin", CIRCLE_ORIGIN[start]);
|
||||
root.dataset.beuiVt = variant;
|
||||
}
|
||||
|
||||
const vt = (
|
||||
document as Document & {
|
||||
startViewTransition(cb: () => void): { finished: Promise<void> };
|
||||
}
|
||||
).startViewTransition(() => setTheme(next));
|
||||
|
||||
vt.finished.finally(() => {
|
||||
delete root.dataset.beuiVt;
|
||||
});
|
||||
};
|
||||
|
||||
return { isDark, mounted, toggle };
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
variant = "rectangle",
|
||||
start = "bottom-up",
|
||||
className,
|
||||
iconClassName,
|
||||
...rest
|
||||
}: ThemeToggleProps) {
|
||||
const { isDark, mounted, toggle } = useThemeToggle({ variant, start });
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
mounted && isDark ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
onClick={toggle}
|
||||
className={cn("flex items-center justify-center", className)}
|
||||
{...rest}
|
||||
>
|
||||
{mounted ? (
|
||||
<ActionSwapIcon
|
||||
value={isDark ? "dark" : "light"}
|
||||
animation="blur"
|
||||
className={iconClassName}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className={iconClassName} />
|
||||
) : (
|
||||
<Moon className={iconClassName} />
|
||||
)}
|
||||
</ActionSwapIcon>
|
||||
) : (
|
||||
<span className={iconClassName} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
56
app/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
261
app/src/components/ui/curved-menu.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Full-page mobile menu with a curved reveal.
|
||||
*
|
||||
* The panel rises behind an SVG whose leading edge starts bowed and flattens
|
||||
* as it lands — a curtain with weight, not a rectangle sliding up.
|
||||
*
|
||||
* Two things that are easy to get wrong here:
|
||||
*
|
||||
* · The dialog must NOT paint its own background. .ca-ink-surface normally
|
||||
* sets background-color, which would make the panel opaque before the
|
||||
* curtain ever moves and hide the entire effect. The class is kept for its
|
||||
* token overrides only; the SVG paints the ground.
|
||||
*
|
||||
* · The panel MUST be portalled to <body>. It lives inside <header>, which
|
||||
* carries backdrop-blur on inner pages — and an ancestor with backdrop-filter
|
||||
* (or transform, or filter) becomes the containing block for position:fixed
|
||||
* descendants. Without the portal the dialog resolves `inset-0` against the
|
||||
* 64px-tall header and renders as a thin strip. The homepage header has no
|
||||
* blur, which is why the bug only ever showed on inner pages.
|
||||
*
|
||||
* · Everything needs an exit, not just the curtain. Without one the copy
|
||||
* hangs in mid-air over the page beneath while the curtain slides away,
|
||||
* which reads as lag rather than animation. Content leaves first and
|
||||
* faster, then the curtain follows it down.
|
||||
*/
|
||||
|
||||
export interface CurvedMenuItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CurvedMenuProps {
|
||||
items: CurvedMenuItem[];
|
||||
current?: string;
|
||||
triggerClassName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** Bowed leading edge → flat. Same command structure so the path can tween. */
|
||||
const CURVE_BOWED = "M0 34 Q 50 -6 100 34 L100 100 L0 100 Z";
|
||||
const CURVE_FLAT = "M0 0 Q 50 0 100 0 L100 100 L0 100 Z";
|
||||
|
||||
const EASE = [0.33, 0.1, 0.25, 1] as const;
|
||||
const OPEN_MS = 0.62;
|
||||
const CLOSE_MS = 0.42; // leaving is quicker than arriving — it always should be
|
||||
|
||||
export function CurvedMenu({
|
||||
items,
|
||||
current = "/",
|
||||
triggerClassName,
|
||||
email = "hi@carlosarias.com",
|
||||
}: CurvedMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) panelRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const isActive = (to: string) =>
|
||||
to === "/" ? current === "/" : current.startsWith(to);
|
||||
|
||||
const bar = "absolute left-0 h-[1.5px] w-full rounded-full bg-current";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The trigger is also the close control: it lifts above the overlay
|
||||
when open and its two bars cross into an X. */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(triggerClassName)}
|
||||
>
|
||||
<span className="relative block h-[14px] w-[22px]" aria-hidden="true">
|
||||
<span className={cn(bar, "top-[3px]")} />
|
||||
<span className={cn(bar, "top-[11px]")} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="curved-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Site menu"
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
/* bg-transparent is load-bearing — see the note at the top. */
|
||||
className="ca-ink-surface fixed inset-0 z-[999] !bg-transparent outline-none"
|
||||
>
|
||||
{reduce ? (
|
||||
<div className="absolute inset-0 bg-background" />
|
||||
) : (
|
||||
<motion.svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: "0%" }}
|
||||
exit={{ y: "100%", transition: { duration: CLOSE_MS, ease: EASE } }}
|
||||
transition={{ duration: OPEN_MS, ease: EASE }}
|
||||
>
|
||||
<motion.path
|
||||
fill="var(--color-background)"
|
||||
initial={{ d: CURVE_BOWED }}
|
||||
animate={{ d: CURVE_FLAT }}
|
||||
exit={{ d: CURVE_BOWED, transition: { duration: CLOSE_MS, ease: EASE } }}
|
||||
transition={{ duration: OPEN_MS, ease: EASE }}
|
||||
/>
|
||||
</motion.svg>
|
||||
)}
|
||||
|
||||
{/* Content leaves first and faster, so nothing is left floating
|
||||
over the page while the curtain drops. */}
|
||||
<motion.div
|
||||
className="relative flex h-full flex-col px-6 pt-6 pb-10"
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.3, delay: 0.26 } }}
|
||||
exit={{ opacity: 0, y: 18, transition: { duration: 0.2, ease: EASE } }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="ca-menu-seal" aria-hidden="true">
|
||||
印
|
||||
</span>
|
||||
|
||||
{/* Lives INSIDE the portal, so it never depends on the header's
|
||||
z-index, ground, or which variant the page rendered. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close menu"
|
||||
className="-mr-3 inline-flex h-12 w-12 items-center justify-center text-foreground"
|
||||
>
|
||||
<span className="relative block h-[14px] w-[22px]" aria-hidden="true">
|
||||
<motion.span
|
||||
className={cn(bar, "top-[3px]")}
|
||||
initial={reduce ? false : { rotate: 0, y: 0 }}
|
||||
animate={{ rotate: 45, y: 4 }}
|
||||
exit={{ rotate: 0, y: 0 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0 }
|
||||
: { duration: 0.34, delay: 0.16, ease: [0.2, 0.9, 0.3, 1] }
|
||||
}
|
||||
/>
|
||||
<motion.span
|
||||
className={cn(bar, "top-[11px]")}
|
||||
initial={reduce ? false : { rotate: 0, y: 0 }}
|
||||
animate={{ rotate: -45, y: -4 }}
|
||||
exit={{ rotate: 0, y: 0 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0 }
|
||||
: { duration: 0.34, delay: 0.16, ease: [0.2, 0.9, 0.3, 1] }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="mt-auto mb-auto flex flex-col gap-1"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const active = isActive(item.to);
|
||||
return (
|
||||
<motion.a
|
||||
key={item.to}
|
||||
href={item.to}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="ca-menu-item"
|
||||
initial={reduce ? false : { opacity: 0, y: 22 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: reduce
|
||||
? { duration: 0 }
|
||||
: { duration: 0.5, delay: 0.3 + i * 0.07, ease: EASE },
|
||||
}}
|
||||
>
|
||||
<span className={active ? "ca-brushed" : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="ca-menu-index" aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</motion.a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<motion.div
|
||||
className="flex flex-col gap-1"
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: reduce ? { duration: 0 } : { duration: 0.5, delay: 0.72 },
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[0.7rem] tracking-[0.16em] text-[var(--ca-ink-4)] uppercase">
|
||||
Get in touch
|
||||
</span>
|
||||
<a href={`mailto:${email}`} className="ca-menu-mail">
|
||||
{email}
|
||||
</a>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CurvedMenu;
|
||||
258
app/src/components/ui/page-not-found.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Animated 404.
|
||||
*
|
||||
* Two deliberate changes from the upstream component:
|
||||
*
|
||||
* 1. Assets are vendored to /404/*.svg instead of hotlinked from a
|
||||
* third-party GitHub raw URL. A 404 page that depends on someone
|
||||
* else's repository staying online is a 404 waiting to 404.
|
||||
*
|
||||
* 2. react-router's useNavigate is replaced with history.back() and a
|
||||
* plain anchor. This is a static Astro site; there is no router.
|
||||
*
|
||||
* Note on colour: the copy is intentionally black on a black ground, and
|
||||
* only becomes readable once the white circle animation has washed over
|
||||
* the screen. That is why the text fades in on a 1200ms delay.
|
||||
*/
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center overflow-x-hidden bg-black">
|
||||
<MessageDisplay />
|
||||
<CharactersAnimation />
|
||||
<CircleAnimation />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageDisplay() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute z-[100] flex h-[90%] w-[90%] flex-col items-center justify-center">
|
||||
<div
|
||||
className={`flex flex-col items-center transition-opacity duration-500 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="m-[1%] text-[35px] font-semibold text-black">Page Not Found</div>
|
||||
<div className="m-[1%] text-[80px] font-bold text-black">404</div>
|
||||
<div className="m-[1%] w-1/2 min-w-[40%] text-center text-[15px] text-black">
|
||||
The page you are looking for might have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</div>
|
||||
<div className="mt-8 flex gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => history.back()}
|
||||
className="flex h-auto items-center gap-2 border-2 border-black px-6 py-2 text-base font-medium text-black transition-all duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Go Back
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="flex h-auto items-center gap-2 bg-black px-6 py-2 text-base font-medium text-white transition-all duration-300 ease-in-out hover:scale-105 hover:bg-gray-900"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StickFigure = {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
src: string;
|
||||
transform?: string;
|
||||
speedX: number;
|
||||
speedRotation?: number;
|
||||
};
|
||||
|
||||
function CharactersAnimation() {
|
||||
const charactersRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stickFigures: StickFigure[] = [
|
||||
{ top: "0%", src: "/404/stick0.svg", transform: "rotateZ(-90deg)", speedX: 1500 },
|
||||
{ top: "10%", src: "/404/stick1.svg", speedX: 3000, speedRotation: 2000 },
|
||||
{ top: "20%", src: "/404/stick2.svg", speedX: 5000, speedRotation: 1000 },
|
||||
{ top: "25%", src: "/404/stick0.svg", speedX: 2500, speedRotation: 1500 },
|
||||
{ top: "35%", src: "/404/stick0.svg", speedX: 2000, speedRotation: 300 },
|
||||
{ bottom: "5%", src: "/404/stick3.svg", speedX: 0 },
|
||||
];
|
||||
|
||||
const host = charactersRef.current;
|
||||
if (host) host.innerHTML = "";
|
||||
|
||||
stickFigures.forEach((figure, index) => {
|
||||
const stick = document.createElement("img");
|
||||
stick.alt = "";
|
||||
stick.classList.add("characters");
|
||||
stick.style.position = "absolute";
|
||||
stick.style.width = "18%";
|
||||
stick.style.height = "18%";
|
||||
|
||||
if (figure.top) stick.style.top = figure.top;
|
||||
if (figure.bottom) stick.style.bottom = figure.bottom;
|
||||
stick.src = figure.src;
|
||||
if (figure.transform) stick.style.transform = figure.transform;
|
||||
|
||||
host?.appendChild(stick);
|
||||
|
||||
if (index === 5) return;
|
||||
|
||||
stick.animate([{ left: "100%" }, { left: "-20%" }], {
|
||||
duration: figure.speedX,
|
||||
easing: "linear",
|
||||
fill: "forwards",
|
||||
});
|
||||
|
||||
if (index === 0) return;
|
||||
|
||||
if (figure.speedRotation) {
|
||||
stick.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(-360deg)" }], {
|
||||
duration: figure.speedRotation,
|
||||
iterations: Infinity,
|
||||
easing: "linear",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (host) host.innerHTML = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={charactersRef} className="absolute h-[95%] w-[99%]" />;
|
||||
}
|
||||
|
||||
interface Circulo {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function CircleAnimation() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const requestIdRef = useRef<number | undefined>(undefined);
|
||||
const timerRef = useRef(0);
|
||||
const circulosRef = useRef<Circulo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const initArr = () => {
|
||||
circulosRef.current = [];
|
||||
for (let index = 0; index < 300; index++) {
|
||||
const randomX =
|
||||
Math.floor(Math.random() * (canvas.width * 3 - canvas.width * 1.2 + 1)) +
|
||||
canvas.width * 1.2;
|
||||
const randomY =
|
||||
Math.floor(Math.random() * (canvas.height - (canvas.height * -0.2 + 1))) +
|
||||
canvas.height * -0.2;
|
||||
circulosRef.current.push({ x: randomX, y: randomY, size: canvas.width / 1000 });
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
timerRef.current++;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
const distanceX = canvas.width / 80;
|
||||
const growthRate = canvas.width / 1000;
|
||||
|
||||
context.fillStyle = "white";
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
circulosRef.current.forEach((circulo) => {
|
||||
context.beginPath();
|
||||
|
||||
if (timerRef.current < 65) {
|
||||
circulo.x -= distanceX;
|
||||
circulo.size += growthRate;
|
||||
}
|
||||
|
||||
if (timerRef.current > 65 && timerRef.current < 500) {
|
||||
circulo.x -= distanceX * 0.02;
|
||||
circulo.size += growthRate * 0.2;
|
||||
}
|
||||
|
||||
context.arc(circulo.x, circulo.y, circulo.size, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
|
||||
if (timerRef.current > 500) {
|
||||
if (requestIdRef.current) cancelAnimationFrame(requestIdRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
requestIdRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
timerRef.current = 0;
|
||||
initArr();
|
||||
draw();
|
||||
|
||||
const handleResize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
timerRef.current = 0;
|
||||
if (requestIdRef.current) cancelAnimationFrame(requestIdRef.current);
|
||||
initArr();
|
||||
draw();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (requestIdRef.current) cancelAnimationFrame(requestIdRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className="h-full w-full" />;
|
||||
}
|
||||
257
app/src/components/ui/resume-intro.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { ArrowUpRight, Download, Mail, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Resume intro block.
|
||||
*
|
||||
* Adapted from the 21st.dev glassmorphism portfolio block. The layout is kept —
|
||||
* two columns, highlight stack, profile card, social list — but every colour,
|
||||
* radius and shadow is driven by the site's tokens rather than the component's
|
||||
* own values.
|
||||
*
|
||||
* Notably NOT carried over: rounded-3xl, blurred glows, and the layered drop
|
||||
* shadows. The brand guide rules those out explicitly (near-square 2px radius,
|
||||
* hairlines instead of elevation), and glassmorphism on a washi ground reads as
|
||||
* a different site. What survives is the structure and the motion.
|
||||
*/
|
||||
|
||||
/**
|
||||
* No brand glyphs. lucide removed them in v1 (they live in simple-icons now),
|
||||
* and adding a second icon package to draw four logos would be the wrong trade
|
||||
* on a page built around restraint. Label plus handle already identifies the
|
||||
* network; a single consistent arrow does the affordance.
|
||||
*/
|
||||
type IconName = string;
|
||||
|
||||
export interface ResumeIntroProps {
|
||||
name: string;
|
||||
headline: string;
|
||||
summary: string;
|
||||
highlights: { title: string; description: string }[];
|
||||
social: { label: string; handle: string; href: string; icon: IconName }[];
|
||||
email: string;
|
||||
resumeHref: string;
|
||||
/**
|
||||
* Portrait for the light theme. Leave undefined to render the placeholder
|
||||
* frame; the frame reserves the same space either way, so adding a photo
|
||||
* later cannot reflow the layout.
|
||||
*/
|
||||
photoSrc?: string;
|
||||
/**
|
||||
* Optional dark-theme portrait. Both are rendered and CSS shows one, because
|
||||
* the theme is class-based (.dark on <html>) rather than media-query based —
|
||||
* a <picture media="(prefers-color-scheme: dark)"> would ignore the site's
|
||||
* own toggle and follow the OS instead.
|
||||
*/
|
||||
photoSrcDark?: string;
|
||||
}
|
||||
|
||||
const listVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, staggerChildren: 0.07 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
const EASE = [0.33, 0.1, 0.25, 1] as const;
|
||||
|
||||
export function ResumeIntro({
|
||||
name,
|
||||
headline,
|
||||
summary,
|
||||
highlights,
|
||||
social,
|
||||
email,
|
||||
resumeHref,
|
||||
photoSrc,
|
||||
photoSrcDark,
|
||||
}: ResumeIntroProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden px-5 py-16 md:py-24">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.6, ease: EASE }}
|
||||
className="relative overflow-hidden rounded-[var(--radius)] border border-border bg-card/60 p-6 backdrop-blur-xl md:p-12"
|
||||
>
|
||||
{/* kintsugi seam, in place of the original's gradient wash */}
|
||||
<span
|
||||
className="absolute top-0 left-[12%] h-px w-11 bg-[var(--ca-seal)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative grid gap-12 lg:grid-cols-[1.35fr_1fr]">
|
||||
<div className="space-y-8">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-[var(--radius)] border-border bg-transparent px-3 py-1 font-mono text-[0.7rem] tracking-[0.2em] text-muted-foreground uppercase"
|
||||
>
|
||||
Curriculum Vitae
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-5">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.08, ease: EASE }}
|
||||
className="font-serif text-[clamp(2rem,5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
{name}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.14, ease: EASE }}
|
||||
className="font-mono text-xs tracking-[0.16em] text-muted-foreground uppercase"
|
||||
>
|
||||
{headline}
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2, ease: EASE }}
|
||||
className="max-w-[62ch] leading-relaxed text-[var(--ca-ink-2)]"
|
||||
>
|
||||
{summary}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0">
|
||||
{highlights.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: 0.1 * index, ease: EASE }}
|
||||
className="border-t border-border py-4 last:border-b"
|
||||
>
|
||||
<p className="font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[58ch] text-sm leading-relaxed text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.3, ease: EASE }}
|
||||
className="flex flex-wrap items-center gap-4"
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 gap-2 rounded-[var(--radius)] px-6 text-sm font-medium"
|
||||
>
|
||||
<a href={resumeHref} download>
|
||||
<Download className="h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</Button>
|
||||
<a href={`mailto:${email}`} className="ca-quiet inline-flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4" />
|
||||
{email}
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Profile column — a seal card rather than an avatar with a glow. */}
|
||||
<div className="relative">
|
||||
<div className="flex h-full flex-col justify-between gap-8 border border-border p-6 md:p-7">
|
||||
<div>
|
||||
{photoSrc ? (
|
||||
<>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={name}
|
||||
className="ca-portrait ca-portrait-light"
|
||||
width={760}
|
||||
height={950}
|
||||
/>
|
||||
{photoSrcDark && (
|
||||
<img
|
||||
src={photoSrcDark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="ca-portrait ca-portrait-dark"
|
||||
width={760}
|
||||
height={950}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="ca-portrait ca-portrait-empty" role="img" aria-label="Portrait placeholder">
|
||||
<User className="h-7 w-7" strokeWidth={1.25} aria-hidden="true" />
|
||||
<span>Portrait</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ca-menu-seal mt-6 inline-flex" aria-hidden="true">
|
||||
印
|
||||
</span>
|
||||
<p className="mt-5 font-serif text-2xl leading-snug tracking-tight">
|
||||
Twenty-six years, one instinct.
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
If a process is repetitive, it should build and run itself. That
|
||||
started on a Commodore 64 in 1988 and it hasn’t changed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={listVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{social.map((s) => (
|
||||
<motion.a
|
||||
key={s.label}
|
||||
variants={itemVariants}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 border-t border-border py-3 last:border-b"
|
||||
>
|
||||
<span className="text-sm">{s.label}</span>
|
||||
<span className="ml-auto font-mono text-[0.7rem] text-[var(--ca-ink-4)]">
|
||||
{s.handle}
|
||||
</span>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-[var(--ca-ink-4)] transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[var(--ca-seal)]" />
|
||||
</motion.a>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResumeIntro;
|
||||
30
app/src/config/capabilities.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[
|
||||
{
|
||||
"title": "Agentic AI systems",
|
||||
"summary": "Multi-agent architectures where AI agents collaborate to solve complex business problems end to end."
|
||||
},
|
||||
{
|
||||
"title": "AI automation",
|
||||
"summary": "Replacing repetitive business processes with intelligent, autonomous workflows."
|
||||
},
|
||||
{
|
||||
"title": "Autonomous websites",
|
||||
"summary": "Sites that continuously improve their own SEO, content, UX, and conversions using AI."
|
||||
},
|
||||
{
|
||||
"title": "AI product engineering",
|
||||
"summary": "AI-first SaaS platforms designed and shipped from concept to production."
|
||||
},
|
||||
{
|
||||
"title": "Intelligent marketing systems",
|
||||
"summary": "AI that runs technical SEO, content planning, competitor research, and optimization on its own."
|
||||
},
|
||||
{
|
||||
"title": "Software architecture",
|
||||
"summary": "APIs, cloud architecture, automation pipelines, distributed systems, and scalable backends."
|
||||
},
|
||||
{
|
||||
"title": "Fractional CTO",
|
||||
"summary": "Technology leadership for startups building AI products."
|
||||
}
|
||||
]
|
||||
182
app/src/config/changelog.json
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
[
|
||||
{
|
||||
"type": "updated",
|
||||
"summary": "Navigation",
|
||||
"detail": "Added Services to the main menu, front and centre.",
|
||||
"entry_at": "2026-07-23T16:12"
|
||||
},
|
||||
{
|
||||
"type": "updated",
|
||||
"summary": "Contact form aligned to services",
|
||||
"detail": "Enquiry types now mirror the three core services offered.",
|
||||
"entry_at": "2026-07-23T16:10"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Services page",
|
||||
"detail": "Automation, website design, and digital marketing — built for law firms and service businesses.",
|
||||
"entry_at": "2026-07-23T16:05"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Footer service links",
|
||||
"detail": "Each service links straight to the contact form with the enquiry already selected.",
|
||||
"entry_at": "2026-07-23T15:35"
|
||||
},
|
||||
{
|
||||
"type": "updated",
|
||||
"summary": "About headline tuned for search",
|
||||
"detail": "Led with agentic AI and automation instead of the origin year — same story, better discoverability.",
|
||||
"entry_at": "2026-07-23T15:20"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "This changelog",
|
||||
"detail": "The site now documents its own evolution — fittingly, an agentic website that logs itself.",
|
||||
"entry_at": "2026-07-23T15:05"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Project galleries and social rail",
|
||||
"detail": "A photo gallery with a lightbox, and a sticky Instagram rail beside each case study.",
|
||||
"entry_at": "2026-07-23T14:50"
|
||||
},
|
||||
{
|
||||
"type": "fixed",
|
||||
"summary": "About copy corrected",
|
||||
"detail": "Separated learning to code (1988) from building software professionally (1999).",
|
||||
"entry_at": "2026-07-23T14:33"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "About page",
|
||||
"detail": "The story from a Commodore 64 in 1988 to agentic AI today.",
|
||||
"entry_at": "2026-07-23T13:52"
|
||||
},
|
||||
{
|
||||
"type": "fixed",
|
||||
"summary": "Menu portalled to the page root",
|
||||
"detail": "The overlay now escapes the header entirely and covers the page identically everywhere.",
|
||||
"entry_at": "2026-07-23T12:45"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Project filtering",
|
||||
"detail": "Filter by category with an animated, shareable, deep-linkable transition.",
|
||||
"entry_at": "2026-07-23T12:44"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Projects moved to the database",
|
||||
"detail": "Per-project case-study pages generated from cja_projects at build time.",
|
||||
"entry_at": "2026-07-23T12:30"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Contact form wired up",
|
||||
"detail": "Real submissions stored in the database, with validation and spam protection.",
|
||||
"entry_at": "2026-07-23T12:14"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Portraits with light/dark variants",
|
||||
"detail": "A wardrobe swap — the right shot for each theme, graded into the palette.",
|
||||
"entry_at": "2026-07-22T15:51"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Resume page",
|
||||
"detail": "Experience, ventures, a grouped skills breakdown, education, and a downloadable PDF.",
|
||||
"entry_at": "2026-07-22T15:24"
|
||||
},
|
||||
{
|
||||
"type": "bug",
|
||||
"summary": "A component import broke the build",
|
||||
"detail": "Aliasing framer-motion to its successor created a circular module reference.",
|
||||
"entry_at": "2026-07-22T15:24"
|
||||
},
|
||||
{
|
||||
"type": "fixed",
|
||||
"summary": "Motion dependency resolved",
|
||||
"detail": "Declared the real package directly instead of aliasing, ending the loop.",
|
||||
"entry_at": "2026-07-22T15:24"
|
||||
},
|
||||
{
|
||||
"type": "bug",
|
||||
"summary": "Mobile menu clipped to the header",
|
||||
"detail": "On inner pages the header's backdrop-blur trapped the fixed overlay to a thin strip.",
|
||||
"entry_at": "2026-07-22T13:44"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Full-page mobile menu",
|
||||
"detail": "A curved reveal with the hamburger morphing into a close control.",
|
||||
"entry_at": "2026-07-22T13:25"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Animated 404 page",
|
||||
"detail": "A playful full-screen 404, with a reduced-motion fallback and locally-hosted assets.",
|
||||
"entry_at": "2026-07-22T13:12"
|
||||
},
|
||||
{
|
||||
"type": "bug",
|
||||
"summary": "Hero rendered paper-on-paper in light mode",
|
||||
"detail": "A scoped colour override could not reach the already-resolved theme tokens.",
|
||||
"entry_at": "2026-07-22T12:58"
|
||||
},
|
||||
{
|
||||
"type": "fixed",
|
||||
"summary": "Ink surface scoping corrected",
|
||||
"detail": "The dark hero now overrides the compiled colour tokens directly, in both themes.",
|
||||
"entry_at": "2026-07-22T12:58"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Theme toggle",
|
||||
"detail": "Light and dark, with an ink-reveal transition that spreads from the button you click.",
|
||||
"entry_at": "2026-07-22T12:49"
|
||||
},
|
||||
{
|
||||
"type": "fixed",
|
||||
"summary": "Schema install repaired",
|
||||
"detail": "Added the standard foreign-key guard to the snapshot migration so tables load in any order.",
|
||||
"entry_at": "2026-07-22T12:42"
|
||||
},
|
||||
{
|
||||
"type": "bug",
|
||||
"summary": "Base schema failed to install",
|
||||
"detail": "A migration declared a foreign key before the table it referenced existed.",
|
||||
"entry_at": "2026-07-22T12:40"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Navigation and brand mark",
|
||||
"detail": "Header signature paired with the hanko seal; nav updated to Projects, Resume, About, Blog, Contact.",
|
||||
"entry_at": "2026-07-22T12:16"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "SeedProject cloned and wired",
|
||||
"detail": "Astro frontend, PHP API, and MariaDB connected end to end.",
|
||||
"entry_at": "2026-07-22T12:05"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "React added as Astro islands",
|
||||
"detail": "Interactive components hydrate only where used; the rest of the site stays static.",
|
||||
"entry_at": "2026-07-22T12:00"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Homepage built",
|
||||
"detail": "Ink hero with the signature writing itself, a typographic work index, and a full-bleed mission line.",
|
||||
"entry_at": "2026-07-22T09:15"
|
||||
},
|
||||
{
|
||||
"type": "added",
|
||||
"summary": "Brand system established",
|
||||
"detail": "Sumi-e direction — ink on washi paper, a single vermillion seal. Colour, type, and motion tokens.",
|
||||
"entry_at": "2026-07-21T16:40"
|
||||
}
|
||||
]
|
||||
82
app/src/config/projects.json
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
[
|
||||
{
|
||||
"slug": "agentic-marketing-platform",
|
||||
"title": "Agentic Marketing Platform",
|
||||
"period": "In development",
|
||||
"status": "building",
|
||||
"summary": "An autonomous digital marketing platform where collaborative agents run technical SEO, competitor research, content planning, local SEO, analytics, and reporting — the full process, run by agents.",
|
||||
"tags": [
|
||||
"Multi-agent",
|
||||
"SEO",
|
||||
"Analytics"
|
||||
],
|
||||
"categories": [
|
||||
"Agentic",
|
||||
"SaaS",
|
||||
"Tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "medellin-co",
|
||||
"title": "Medellin.co",
|
||||
"period": "2020 — present",
|
||||
"status": "live",
|
||||
"summary": "An AI-powered city platform using RAG, intelligent search, autonomous content generation, and AI assistants for travelers, expats, and local businesses.",
|
||||
"tags": [
|
||||
"RAG",
|
||||
"Search",
|
||||
"Content"
|
||||
],
|
||||
"categories": [
|
||||
"Websites",
|
||||
"SaaS",
|
||||
"Agentic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "snoopi-io",
|
||||
"title": "Snoopi.io",
|
||||
"period": "2014 — present",
|
||||
"status": "live",
|
||||
"summary": "A geolocation SaaS platform delivering IP intelligence, APIs, and location services for developers and enterprise applications.",
|
||||
"tags": [
|
||||
"SaaS",
|
||||
"APIs",
|
||||
"Geospatial"
|
||||
],
|
||||
"categories": [
|
||||
"SaaS",
|
||||
"Tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "crawllr",
|
||||
"title": "Crawllr",
|
||||
"period": "2025 — present",
|
||||
"status": "live",
|
||||
"summary": "An intelligent web crawler built for large-scale data collection, knowledge extraction, and AI indexing.",
|
||||
"tags": [
|
||||
"Crawling",
|
||||
"Knowledge extraction"
|
||||
],
|
||||
"categories": [
|
||||
"Tools",
|
||||
"Agentic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "gabii",
|
||||
"title": "Gabii",
|
||||
"period": "In development",
|
||||
"status": "building",
|
||||
"summary": "An AI-powered relationship platform using intelligent profiling, compatibility analysis, and autonomous matchmaking.",
|
||||
"tags": [
|
||||
"Profiling",
|
||||
"Matching"
|
||||
],
|
||||
"categories": [
|
||||
"SaaS",
|
||||
"Agentic"
|
||||
]
|
||||
}
|
||||
]
|
||||
368
app/src/config/resume.json
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
{
|
||||
"headline": "Software Engineer · Product Engineer · Digital Marketing Strategist",
|
||||
"summary": "Over 25 years in software engineering and digital marketing, building scalable SaaS platforms and leading cross-functional teams. In recent years I've focused on AI and machine learning — generative AI, LLMs, vector databases, embedding models, and the full RAG pipeline — applied in production SaaS across fraud prevention, identity verification, and content generation.",
|
||||
"contact": {
|
||||
"phone": "561-460-0660",
|
||||
"email": "hi@carlosarias.com",
|
||||
"site": "carlosarias.co"
|
||||
},
|
||||
"social": [
|
||||
{
|
||||
"label": "LinkedIn",
|
||||
"handle": "@hicarlosarias",
|
||||
"href": "https://www.linkedin.com/in/hicarlosarias/",
|
||||
"icon": "linkedin"
|
||||
},
|
||||
{
|
||||
"label": "X",
|
||||
"handle": "@hicarlosarias",
|
||||
"href": "https://x.com/hicarlosarias",
|
||||
"icon": "x"
|
||||
},
|
||||
{
|
||||
"label": "Instagram",
|
||||
"handle": "@carlosarias.co",
|
||||
"href": "https://instagram.com/carlosarias.co",
|
||||
"icon": "instagram"
|
||||
},
|
||||
{
|
||||
"label": "YouTube",
|
||||
"handle": "@hiCarlosArias",
|
||||
"href": "https://www.youtube.com/@hiCarlosArias",
|
||||
"icon": "youtube"
|
||||
},
|
||||
{
|
||||
"label": "GitHub",
|
||||
"handle": "@CarlosJa",
|
||||
"href": "https://github.com/CarlosJa",
|
||||
"icon": "github"
|
||||
}
|
||||
],
|
||||
"highlights": [
|
||||
{
|
||||
"title": "Focus",
|
||||
"description": "Agentic AI and automation — multi-agent systems that research, plan, build, and optimise with minimal human intervention."
|
||||
},
|
||||
{
|
||||
"title": "Industries",
|
||||
"description": "FinTech since 2014 · healthcare · SaaS · legal · geospatial. Security and compliance as a default: HIPAA, SOC 2, PCI."
|
||||
},
|
||||
{
|
||||
"title": "Track record",
|
||||
"description": "Grew a credit-reporting SaaS past $1M ARR · manages $1M+ in annual ad spend · 200+ pages ranked for Medellin.co."
|
||||
}
|
||||
],
|
||||
"experience": [
|
||||
{
|
||||
"role": "Digital Marketing Strategist & Engineer",
|
||||
"org": "CarlosArias & Co",
|
||||
"sector": "Independent",
|
||||
"period": "Jul 1999 — Present",
|
||||
"current": true,
|
||||
"summary": "Fractional marketing director for law firms across personal injury, family law, bankruptcy, and tax resolution. Full-funnel strategy — SEO, PPC, Google Business Profile, local service pages, LSA, conversion tracking, and OTT. Current focus is generative SEO, AI integration, and scalable marketing frameworks that improve organic case acquisition.",
|
||||
"points": [
|
||||
"SEO strategy and content built to rank in AI engines, not just search engines",
|
||||
"Manages over $1M in PPC campaigns with partners including DrivenLeads and WordStream",
|
||||
"Link building, digital PR, and directory strategy executed through overseas teams",
|
||||
"Analytics and Search Console instrumentation for end-to-end attribution",
|
||||
"Tooling: CallRail, Swydo, n8n, SEMrush, Ahrefs, ClickUp, Notion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "Digital Marketing Director & SEO Specialist",
|
||||
"org": "CreditGuard of America",
|
||||
"sector": "Debt consolidation",
|
||||
"period": "Jan 2022 — Jan 2025",
|
||||
"summary": "Inherited the marketing function for CreditGuard, sister company to GNUSA. Built the strategy to generate leads, rebuild the site and landing pages, and drive calls into the call centre.",
|
||||
"points": [
|
||||
"Increased organic traffic 35% and improved lead generation",
|
||||
"Lowered CPA by 70% and increased lead conversion by 30%",
|
||||
"Email programme reaching up to 70% open rate",
|
||||
"Built the affiliate marketing programme from scratch",
|
||||
"Ran PPC across Google, Bing, and Meta in synergy with SEO"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "Lead Software Engineer II",
|
||||
"org": "GNUSA",
|
||||
"sector": "FinTech",
|
||||
"period": "Jun 2016 — Jan 2022",
|
||||
"summary": "Brought on to build a SaaS platform and maintain legacy systems. Took the credit-reporting SaaS concept from MVP through product launch and growth.",
|
||||
"points": [
|
||||
"Architected and engineered the GNUSA credit-reporting SaaS platform",
|
||||
"Grew the platform past $1M ARR",
|
||||
"Integrated Equifax and TransUnion credit bureaus",
|
||||
"Database administration: security, ERD design, restoration, and compliance",
|
||||
"Led overseas development teams and coordinated feature delivery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "Chief Technology Officer (Fractional)",
|
||||
"org": "GypsyNurse",
|
||||
"sector": "Nursing staffing",
|
||||
"period": "Jun 2016 — Jan 2017",
|
||||
"summary": "Part-time CTO taking over development of a new platform, managing the overseas team and keeping technology aligned to the business vision.",
|
||||
"points": [
|
||||
"Owned delivery of the platform on time and on budget",
|
||||
"Third-party API and payment gateway integration",
|
||||
"Reported status and budget to shareholders",
|
||||
"Led and coordinated the overseas engineering team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "Chief Technology Officer (Fractional)",
|
||||
"org": "HealthCareForYou",
|
||||
"sector": "Health insurance",
|
||||
"period": "Jun 2014 — Jan 2017",
|
||||
"summary": "Part-time CTO moving an insurance business from a traditional process to digital sales, and aligning it with Healthcare.gov to be first to market with online healthcare signups.",
|
||||
"points": [
|
||||
"Integrated the business with Healthcare.gov / ACA enrolment",
|
||||
"First to market in their segment with online healthcare signup",
|
||||
"Database security, ERD design, and compliance",
|
||||
"Led overseas development teams"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "SEO Specialist & Sr. Full-Stack Developer",
|
||||
"org": "TouchSuite",
|
||||
"sector": "Payments",
|
||||
"period": "Jun 2011 — Aug 2014",
|
||||
"summary": "Hired to build the SEO and web development department from nothing, and to move the company from traditional sales into digital.",
|
||||
"points": [
|
||||
"Built and led the web development and SEO departments",
|
||||
"Digitised the sales process by integrating Equifax and DocuSign into signup",
|
||||
"Hired and managed both SEO and engineering teams",
|
||||
"Raised conversion through high-intent landing pages and SEO"
|
||||
]
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "Medellin.co",
|
||||
"kind": "SaaS / Media",
|
||||
"period": "Since Oct 2022",
|
||||
"summary": "The largest resource hub for Medellín, Colombia — used by locals, expats, and tourists. Acquired the domain from its previous owner after the city released it.",
|
||||
"points": [
|
||||
"200+ pages optimised, roughly 70% ranking on page one",
|
||||
"Domain authority grown from 10 to 30",
|
||||
"Chatbot on OpenAI LLM with Zilliz vector DB and OpenAI embeddings",
|
||||
"Custom PHP integrating directly with TikTok and Instagram",
|
||||
"Direct integration with law enforcement for filing criminal reports",
|
||||
"Stripe checkout for advertisers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Verificaa.com",
|
||||
"kind": "SaaS",
|
||||
"period": "Since Jun 2021",
|
||||
"summary": "ID verification and fraud prevention for Latin America, letting people verify businesses and individuals before doing business with them. The platform has helped save two lives and prevented losses for numerous businesses.",
|
||||
"points": [
|
||||
"AI and LLM-driven fraud detection infrastructure",
|
||||
"Integrations with police, attorney general, and business databases",
|
||||
"Data-mining pipeline that crawls and normalises open Colombian and LATAM data",
|
||||
"REST API with API key management",
|
||||
"Custom PHP dashboard with complex user management"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Snoopi.io",
|
||||
"kind": "SaaS",
|
||||
"period": "Since Mar 2015",
|
||||
"summary": "GeoIP location and IP fraud prevention. Started as a way to learn PHP OOP and build a REST API; ten years later, rebuilt with a modern dashboard and AI analysis on the roadmap.",
|
||||
"points": [
|
||||
"Real-time tracking script for sites and landing pages",
|
||||
"GeoIP-driven landing page optimisation with AI",
|
||||
"Custom dashboard, invoice and subscription management",
|
||||
"Stripe integration with a custom card form"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Crypto AI Trading Bot",
|
||||
"kind": "Private",
|
||||
"period": "Since Mar 2022",
|
||||
"summary": "A personal bot that follows a defined trading strategy, since upgraded to bring AI into the decision loop.",
|
||||
"points": [
|
||||
"Binance integration, moving to Bitfinex",
|
||||
"Real-time charting with custom buy/sell algorithms",
|
||||
"LLM monitoring of market conditions"
|
||||
]
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"group": "Engineering",
|
||||
"items": [
|
||||
{
|
||||
"name": "Polyglot programming",
|
||||
"years": 25
|
||||
},
|
||||
{
|
||||
"name": ".NET, ASP, PHP, Java, Python, Bash",
|
||||
"years": 25
|
||||
},
|
||||
{
|
||||
"name": "Database design & architecture",
|
||||
"years": 20
|
||||
},
|
||||
{
|
||||
"name": "API integration",
|
||||
"years": 20
|
||||
},
|
||||
{
|
||||
"name": "Cloud architecture (Vultr, AWS)",
|
||||
"years": 10
|
||||
},
|
||||
{
|
||||
"name": "Cybersecurity & data protection",
|
||||
"years": 10
|
||||
},
|
||||
{
|
||||
"name": "Linux administration (L1–L2)",
|
||||
"years": 10
|
||||
},
|
||||
{
|
||||
"name": "React, TypeScript, JavaScript",
|
||||
"years": 7
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "AI",
|
||||
"items": [
|
||||
{
|
||||
"name": "OpenAI, Mistral, Anthropic, Llama",
|
||||
"years": 3
|
||||
},
|
||||
{
|
||||
"name": "LLM integration & prompting",
|
||||
"years": 2
|
||||
},
|
||||
{
|
||||
"name": "RAG, vector DBs, embeddings",
|
||||
"years": 2
|
||||
},
|
||||
{
|
||||
"name": "Agentic systems & orchestration",
|
||||
"years": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Marketing",
|
||||
"items": [
|
||||
{
|
||||
"name": "Search engine optimisation",
|
||||
"years": 25
|
||||
},
|
||||
{
|
||||
"name": "Graphic design",
|
||||
"years": 25
|
||||
},
|
||||
{
|
||||
"name": "Digital marketing",
|
||||
"years": 20
|
||||
},
|
||||
{
|
||||
"name": "Traditional marketing",
|
||||
"years": 20
|
||||
},
|
||||
{
|
||||
"name": "Digital marketing strategy",
|
||||
"years": 15
|
||||
},
|
||||
{
|
||||
"name": "PPC — Google, Bing, Meta",
|
||||
"years": 15
|
||||
},
|
||||
{
|
||||
"name": "UX & usability",
|
||||
"years": 10
|
||||
},
|
||||
{
|
||||
"name": "Social media strategy",
|
||||
"years": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Leadership",
|
||||
"items": [
|
||||
{
|
||||
"name": "Technical team leadership"
|
||||
},
|
||||
{
|
||||
"name": "Hiring & team development"
|
||||
},
|
||||
{
|
||||
"name": "Project management"
|
||||
},
|
||||
{
|
||||
"name": "SaaS launch strategy"
|
||||
},
|
||||
{
|
||||
"name": "Project & department budgeting"
|
||||
},
|
||||
{
|
||||
"name": "Agile & Scrum"
|
||||
},
|
||||
{
|
||||
"name": "Technology & business alignment"
|
||||
},
|
||||
{
|
||||
"name": "Product engineering"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"Google Analytics",
|
||||
"Looker Studio",
|
||||
"Search Console",
|
||||
"Ahrefs",
|
||||
"SEMrush",
|
||||
"Google Ads",
|
||||
"Bing Ads",
|
||||
"Meta Business",
|
||||
"WooCommerce",
|
||||
"Magento",
|
||||
"Zoho CRM",
|
||||
"Salesforce",
|
||||
"HubSpot",
|
||||
"Brevo",
|
||||
"SendGrid",
|
||||
"Adobe Suite",
|
||||
"Figma",
|
||||
"Sketch",
|
||||
"Canva",
|
||||
"CapCut",
|
||||
"Trello",
|
||||
"Asana",
|
||||
"Jira",
|
||||
"WordPress",
|
||||
"Stripe",
|
||||
"Authorize.Net",
|
||||
"n8n"
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"title": "BA, Computer Information Systems & Marketing",
|
||||
"org": "DeVry University",
|
||||
"period": "Graduated May 2004"
|
||||
},
|
||||
{
|
||||
"title": "MIT Management — Blockchain",
|
||||
"org": "Massachusetts Institute of Technology",
|
||||
"period": "Feb — Mar 2022"
|
||||
}
|
||||
],
|
||||
"certifications": [
|
||||
{
|
||||
"title": "Google Ads Certification",
|
||||
"period": "Jan 2024"
|
||||
},
|
||||
{
|
||||
"title": "Magento 2.0 Certified",
|
||||
"period": "Mar 2022"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
app/src/config/services.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"services": [
|
||||
{
|
||||
"slug": "automation",
|
||||
"name": "Automation",
|
||||
"subject": "automation",
|
||||
"lead": "AI automation that runs the repetitive work your team does by hand.",
|
||||
"body": "I build autonomous systems and AI agents that take over the workflows eating your team's time — lead intake, follow-up, research, scheduling, reporting, content. The goal isn't another dashboard; it's software that does the work, so your people spend their time only on what software can't.",
|
||||
"points": [
|
||||
"AI agents that replace whole workflows, not just single tasks",
|
||||
"Lead intake, qualification, and instant follow-up",
|
||||
"Research, reporting, and content generated on their own",
|
||||
"Systems that monitor results and adapt over time"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "website-design",
|
||||
"name": "Website Design",
|
||||
"subject": "website-design",
|
||||
"page": "/services/website-design",
|
||||
"lead": "Fast, modern websites engineered to convert and rank.",
|
||||
"body": "Custom sites built for speed, search, and conversion — not dragged out of a template. Engineered to bring in qualified leads, hold up under real traffic, and rank in both search engines and the AI engines that are starting to send traffic. Optionally agentic: a site that improves its own SEO, content, and conversions over time.",
|
||||
"points": [
|
||||
"Custom-built, not templated — fast and search-ready",
|
||||
"Designed around one thing: turning visitors into leads",
|
||||
"Local landing pages that rank for the work you want",
|
||||
"Can be made agentic — a site that optimises itself"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "digital-marketing",
|
||||
"name": "Digital Marketing",
|
||||
"subject": "digital-marketing",
|
||||
"lead": "Technical SEO and marketing systems that bring in cases and customers.",
|
||||
"body": "Twenty-five years of SEO, PPC, and local search — now run with AI doing the heavy lifting. I build the acquisition engine: technical SEO, Google Business Profile, local service pages, call tracking, and campaigns that turn search traffic into booked work. Managed by intelligent systems, not manual busywork.",
|
||||
"points": [
|
||||
"Technical SEO built to rank in search and AI engines",
|
||||
"Google Business Profile and local search optimisation",
|
||||
"PPC and campaign management, tracked end to end",
|
||||
"Call and conversion tracking, so you know what works"
|
||||
]
|
||||
}
|
||||
],
|
||||
"primaryVertical": "Law firms",
|
||||
"verticals": [
|
||||
{ "name": "Law firms", "note": "Personal injury, family, bankruptcy, tax — more qualified cases, faster intake.", "primary": true },
|
||||
{ "name": "Roofing & home services", "note": "Local search dominance and instant lead follow-up for the trades." },
|
||||
{ "name": "Accounting", "note": "Steady, qualified enquiries and less time lost to manual admin." },
|
||||
{ "name": "Service businesses", "note": "Any business that wins or loses on lead volume and response time." }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,20 +1,37 @@
|
|||
{
|
||||
"name": "SeedProject Site",
|
||||
"url": "https://example.com",
|
||||
"description": "A short one-line description of this site, used for SEO and social sharing.",
|
||||
"tagline": "A starter site built on SeedProject",
|
||||
"name": "Carlos Arias",
|
||||
"url": "https://carlosarias.co",
|
||||
"description": "Agentic AI & Automation Engineer building autonomous software, AI agents, and intelligent business systems.",
|
||||
"tagline": "Autonomous systems that do the work teams used to do by hand.",
|
||||
"language": "en",
|
||||
"author": {
|
||||
"slug": "site-author",
|
||||
"name": "Site Author",
|
||||
"bio": "One-line author bio — who runs this site.",
|
||||
"longBio": "A longer author biography paragraph describing who runs this site and their background.",
|
||||
"avatar": "/avatar-placeholder.png",
|
||||
"jobTitle": "Founder & Editor",
|
||||
"knowsAbout": [],
|
||||
"sameAs": []
|
||||
"slug": "carlos-arias",
|
||||
"name": "Carlos Arias",
|
||||
"bio": "Agentic AI & Automation Engineer building autonomous software and intelligent business systems.",
|
||||
"longBio": "Carlos Arias is an Agentic AI & Automation Engineer who builds autonomous software, AI agents, and intelligent business systems. He has been coding since age eight, on a Commodore 64 in 1988, and has spent 26 years as a Software Engineer, CTO, and Technology Advisor across FinTech, healthcare, SaaS, and geospatial technology. Since 2022 his focus has been entirely on agentic AI — designing multi-agent systems that research, plan, build, and optimize on their own.",
|
||||
"avatar": "/carlos-arias.jpg",
|
||||
"jobTitle": "Agentic AI & Automation Engineer",
|
||||
"knowsAbout": [
|
||||
"Agentic AI Systems",
|
||||
"Multi-Agent Collaboration",
|
||||
"AI Automation",
|
||||
"Autonomous Websites",
|
||||
"AI Product Engineering",
|
||||
"Retrieval-Augmented Generation",
|
||||
"AI Workflow Orchestration",
|
||||
"Software Architecture",
|
||||
"Technical SEO",
|
||||
"Fractional CTO"
|
||||
],
|
||||
"sameAs": [
|
||||
"https://www.linkedin.com/in/hicarlosarias/",
|
||||
"https://x.com/hicarlosarias",
|
||||
"https://instagram.com/carlosarias.co",
|
||||
"https://www.youtube.com/@hiCarlosArias",
|
||||
"https://github.com/CarlosJa"
|
||||
]
|
||||
},
|
||||
"social": {
|
||||
"twitter": ""
|
||||
"twitter": "@hicarlosarias"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
app/src/config/website-design.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"eyebrow": "Website Design & Development",
|
||||
"title": "Law firm websites that turn searches into signed cases.",
|
||||
"lead": "Most legal searches happen on a phone, often from someone who needs help now. Your website has a few seconds to load, earn trust, and make it effortless to call. I build sites that do exactly that.",
|
||||
"intro": "A law firm website is not a brochure — it is an intake tool. Every decision, from how fast a page loads to where the call button sits, is made to turn a stranger doing a Google search into a booked consultation. I design and build custom sites for firms, engineered for speed, local search, and conversion, with the option to make them agentic so they keep improving on their own.",
|
||||
"included": [
|
||||
{ "title": "Built to convert", "note": "Click-to-call, clear intake forms, and calls to action placed where they actually get used — not buried below the fold." },
|
||||
{ "title": "Fast on mobile", "note": "Most legal searches are mobile and urgent. Pages load in a heartbeat, because a slow site is a lost case." },
|
||||
{ "title": "Local SEO ready", "note": "Structured to rank for the searches that matter — “personal injury lawyer near me,” practice area plus city — and in AI search too." },
|
||||
{ "title": "Trust that reads instantly", "note": "Case results, reviews, credentials, and bar admissions surfaced where a nervous prospect looks first." },
|
||||
{ "title": "Practice-area pages that rank", "note": "A real page for each service you offer, written and structured to win its own searches." },
|
||||
{ "title": "Optionally agentic", "note": "A site that monitors its own performance and improves its SEO, content, and conversions over time." }
|
||||
],
|
||||
"process": [
|
||||
{ "step": "01", "title": "Discovery", "note": "Your practice areas, your best cases, the clients you want more of, and what the competition is doing." },
|
||||
{ "step": "02", "title": "Design", "note": "A look that reads as credible and current — built around intake, not decoration." },
|
||||
{ "step": "03", "title": "Build & launch", "note": "Fast, custom, search-ready. Migrated carefully so you keep the rankings you already have." },
|
||||
{ "step": "04", "title": "Optimise", "note": "Track calls and conversions, then keep tuning — manually or with agents doing the work." }
|
||||
],
|
||||
"portfolio": [
|
||||
{
|
||||
"name": "Law Office of Ginny L. Goldman",
|
||||
"practice": "Business & Contract Law · Boca Raton, FL",
|
||||
"url": "https://ginnygoldman.com",
|
||||
"image": "/portfolio/ginny-goldman.jpg",
|
||||
"note": "A credible, trust-first design for a boutique contract practice, with click-to-call and clear intake front and centre."
|
||||
},
|
||||
{
|
||||
"name": "Your firm here",
|
||||
"practice": "Add a project",
|
||||
"url": "",
|
||||
"image": "",
|
||||
"note": "A recent law firm build — screenshot and details to be added."
|
||||
},
|
||||
{
|
||||
"name": "Your firm here",
|
||||
"practice": "Add a project",
|
||||
"url": "",
|
||||
"image": "",
|
||||
"note": "A recent law firm build — screenshot and details to be added."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -27,7 +27,9 @@ const absoluteUrl = (value) => {
|
|||
}
|
||||
};
|
||||
const canonicalUrl = absoluteUrl(canonical || Astro.url.pathname);
|
||||
const ogImageUrl = absoluteUrl(ogImage);
|
||||
// Default share card (the branded portrait + tagline). Any page can override
|
||||
// by passing its own `ogImage`.
|
||||
const ogImageUrl = absoluteUrl(ogImage || "/og-image.jpg");
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
|
|
@ -44,11 +46,21 @@ const ogImageUrl = absoluteUrl(ogImage);
|
|||
<meta property="og:site_name" content={SITE.name} />
|
||||
{canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
|
||||
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
{ogImageUrl && <meta property="og:image:width" content="1200" />}
|
||||
{ogImageUrl && <meta property="og:image:height" content="630" />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImageUrl && <meta name="twitter:image" content={ogImageUrl} />}
|
||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||
|
||||
{/* Favicons — the vermillion 印 seal. SVG for modern browsers, PNG/ICO
|
||||
fallbacks, and an apple-touch-icon for home-screen bookmarks. */}
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#d0342c" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter-latin.woff2"
|
||||
|
|
|
|||
77
app/src/lib/changelog.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import mysql from "mysql2/promise";
|
||||
import fallback from "../config/changelog.json";
|
||||
|
||||
/**
|
||||
* Changelog entries, read from cja_changelog at BUILD time.
|
||||
*
|
||||
* Same pattern as lib/projects.js: query once during the build, ship static
|
||||
* HTML, and fall back to the committed JSON if the database is unreachable so a
|
||||
* DB outage can never fail the build. The fallback is regenerated from the DB
|
||||
* whenever the changelog is reseeded, so the two stay in step.
|
||||
*/
|
||||
|
||||
const env = import.meta.env;
|
||||
let cache = null;
|
||||
|
||||
export async function getChangelog() {
|
||||
if (cache) return cache;
|
||||
|
||||
const { DB_HOST: host, DB_NAME: database, DB_USER: user, DB_PASS: password } = env;
|
||||
|
||||
if (!host || !database || !user) {
|
||||
console.warn("[changelog] No DB credentials — using config/changelog.json");
|
||||
cache = fallback;
|
||||
return cache;
|
||||
}
|
||||
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection({ host, user, password, database });
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT type, summary, detail,
|
||||
DATE_FORMAT(entry_at, '%Y-%m-%dT%H:%i') AS entry_at
|
||||
FROM cja_changelog
|
||||
WHERE published = 1
|
||||
ORDER BY entry_at DESC`,
|
||||
);
|
||||
cache = rows.length ? rows : fallback;
|
||||
console.log(`[changelog] loaded ${cache.length} entries`);
|
||||
} catch (error) {
|
||||
console.error(`[changelog] DB read failed (${error.message}) — using fallback`);
|
||||
cache = fallback;
|
||||
} finally {
|
||||
await conn?.end();
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group entries by calendar day, newest first, each day's entries newest first.
|
||||
* Returns [{ dateKey, dateLabel, items: [...] }].
|
||||
*/
|
||||
export async function getChangelogByDay() {
|
||||
const entries = await getChangelog();
|
||||
const days = new Map();
|
||||
|
||||
for (const e of entries) {
|
||||
const dateKey = e.entry_at.slice(0, 10); // YYYY-MM-DD
|
||||
if (!days.has(dateKey)) days.set(dateKey, []);
|
||||
days.get(dateKey).push(e);
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return [...days.entries()]
|
||||
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
||||
.map(([dateKey, items]) => ({
|
||||
dateKey,
|
||||
// parse as UTC noon to avoid any timezone date-shift
|
||||
dateLabel: fmt.format(new Date(`${dateKey}T12:00:00Z`)),
|
||||
items,
|
||||
}));
|
||||
}
|
||||
77
app/src/lib/next-themes.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* A `next-themes` stand-in.
|
||||
*
|
||||
* Most shadcn / 21st.dev components import `useTheme` from "next-themes",
|
||||
* which is a Next.js package and cannot run here. Rather than editing every
|
||||
* component you paste in — which defeats the point of copy-paste components —
|
||||
* Vite aliases the "next-themes" specifier to this file (see astro.config.mjs).
|
||||
*
|
||||
* It speaks the same API surface the components actually use
|
||||
* (`theme`, `resolvedTheme`, `setTheme`, `systemTheme`) but drives the
|
||||
* existing class-based theme already implemented in BaseLayout and Header:
|
||||
* a `.dark` class on <html> plus a `theme` key in localStorage.
|
||||
*
|
||||
* That means the React components and the plain-Astro header stay in sync —
|
||||
* toggling either one updates the other.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
|
||||
const prefersDark = () =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const currentIsDark = () =>
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark");
|
||||
|
||||
export function useTheme() {
|
||||
// Start undefined so server render and first client render agree; components
|
||||
// gate on `mounted` before reading it, which is why they all do that dance.
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark" | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedTheme(currentIsDark() ? "dark" : "light");
|
||||
|
||||
// Keep in step with the vanilla toggle in Header.astro.
|
||||
const observer = new MutationObserver(() => {
|
||||
setResolvedTheme(currentIsDark() ? "dark" : "light");
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
const dark = next === "system" ? prefersDark() : next === "dark";
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
try {
|
||||
if (next === "system") localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode — the class still applies for this session */
|
||||
}
|
||||
setResolvedTheme(dark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
theme: resolvedTheme as Theme | undefined,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
systemTheme: (prefersDark() ? "dark" : "light") as "light" | "dark",
|
||||
themes: ["light", "dark", "system"] as Theme[],
|
||||
};
|
||||
}
|
||||
|
||||
/** No-op: the theme class is applied by the inline script in BaseLayout. */
|
||||
export function ThemeProvider({ children }: { children?: React.ReactNode }) {
|
||||
return children ?? null;
|
||||
}
|
||||
113
app/src/lib/projects.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import mysql from "mysql2/promise";
|
||||
import fallback from "../config/projects.json";
|
||||
|
||||
/**
|
||||
* Projects, read from cja_projects at BUILD time.
|
||||
*
|
||||
* The site is static, so this runs once during `npm run build` and every
|
||||
* project page ships as real HTML — crawlable, instant, no client-side fetch
|
||||
* and no loading state. The cost is that content changes need a rebuild, which
|
||||
* is exactly what the agent pipeline already does.
|
||||
*
|
||||
* If the database is unreachable the build does NOT fail: it falls back to
|
||||
* config/projects.json and logs loudly. A dead database should never be able
|
||||
* to take the whole site down with it.
|
||||
*/
|
||||
|
||||
const env = import.meta.env;
|
||||
|
||||
let cache = null;
|
||||
|
||||
const parseJson = (value, whenEmpty) => {
|
||||
if (!value) return whenEmpty;
|
||||
if (typeof value === "object") return value; // mysql2 may pre-parse JSON columns
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return whenEmpty;
|
||||
}
|
||||
};
|
||||
|
||||
/** Shape a DB row into what the templates expect. */
|
||||
const toProject = (row) => ({
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
period: row.period_label,
|
||||
status: row.status,
|
||||
summary: row.summary,
|
||||
lede: row.lede,
|
||||
body: row.body,
|
||||
role: row.role,
|
||||
categories: parseJson(row.categories, []),
|
||||
stack: parseJson(row.stack, []),
|
||||
skills: parseJson(row.skills, []),
|
||||
metrics: parseJson(row.metrics, []),
|
||||
gallery: parseJson(row.gallery, []),
|
||||
links: parseJson(row.links, {}),
|
||||
cover: row.cover_image || null,
|
||||
coverAlt: row.cover_alt || "",
|
||||
seoTitle: row.seo_title || null,
|
||||
seoDescription: row.seo_description || null,
|
||||
featured: Boolean(row.featured),
|
||||
});
|
||||
|
||||
export async function getProjects() {
|
||||
if (cache) return cache;
|
||||
|
||||
const host = env.DB_HOST;
|
||||
const database = env.DB_NAME;
|
||||
const user = env.DB_USER;
|
||||
const password = env.DB_PASS;
|
||||
|
||||
if (!host || !database || !user) {
|
||||
console.warn(
|
||||
"[projects] No database credentials in env — falling back to config/projects.json",
|
||||
);
|
||||
cache = fallback;
|
||||
return cache;
|
||||
}
|
||||
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection({ host, user, password, database });
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT * FROM cja_projects
|
||||
WHERE published = 1
|
||||
ORDER BY sort_order ASC, started_on DESC`,
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
console.warn("[projects] cja_projects returned no published rows — using fallback");
|
||||
cache = fallback;
|
||||
} else {
|
||||
cache = rows.map(toProject);
|
||||
console.log(`[projects] loaded ${cache.length} from cja_projects`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Loud, but not fatal. A build that silently ships stale content is worse
|
||||
// than one that tells you the database was unreachable.
|
||||
console.error(`[projects] DB read failed (${error.message}) — using fallback`);
|
||||
cache = fallback;
|
||||
} finally {
|
||||
await conn?.end();
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
export async function getProject(slug) {
|
||||
return (await getProjects()).find((p) => p.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
/** Category names with counts, derived from the projects themselves. */
|
||||
export async function getCategories() {
|
||||
const projects = await getProjects();
|
||||
const counts = {};
|
||||
for (const p of projects) {
|
||||
for (const c of p.categories ?? []) counts[c] = (counts[c] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([name, count]) => ({ name, count, slug: name.toLowerCase() }));
|
||||
}
|
||||
10
app/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Merge Tailwind class lists, letting later classes win conflicts.
|
||||
* shadcn and 21st.dev components import this from "@/lib/utils".
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
225
app/src/pages/.index.astro.orig
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import Newsletter from "../components/Newsletter.astro";
|
||||
import Icon from "../components/Icon.astro";
|
||||
import heroImage from "../assets/hero-placeholder.png";
|
||||
import {
|
||||
SITE,
|
||||
categories,
|
||||
featuredPost,
|
||||
sortedPosts,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
formatDate,
|
||||
organizationSchema,
|
||||
websiteSchema,
|
||||
} from "../lib/blog-data.js";
|
||||
|
||||
const featured = await featuredPost();
|
||||
const featuredAuthor = getAuthor(featured.author);
|
||||
const featuredCategory = getCategory(featured.category);
|
||||
const latest = (await sortedPosts()).filter((post) => post.slug !== featured.slug);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [organizationSchema(), websiteSchema()],
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${SITE.name} — ${SITE.tagline}`}
|
||||
description={SITE.description}
|
||||
canonical="/"
|
||||
jsonLd={jsonLd}
|
||||
flushFooter
|
||||
>
|
||||
<section class="relative -mt-[calc(4rem+2px)] min-h-[calc(100svh-2rem)] overflow-hidden border-b border-border/60 pt-[calc(4rem+2px)] sm:min-h-[760px]">
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt="Hero image placeholder — replace in app/src/assets"
|
||||
class="absolute inset-0 h-full w-full object-cover object-center"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
widths={[640, 960, 1280, 1920, 2560]}
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 bg-[linear-gradient(90deg,oklch(0.1_0.02_70_/_0.68),oklch(0.16_0.02_80_/_0.38)_46%,oklch(0.98_0.01_90_/_0.1))] dark:bg-[linear-gradient(90deg,oklch(0.08_0.01_70_/_0.78),oklch(0.08_0.01_70_/_0.52)_48%,oklch(0.08_0.01_70_/_0.18))]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/45 via-black/18 to-transparent"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/72 to-transparent"
|
||||
/>
|
||||
<div class="relative mx-auto flex min-h-[calc(100svh-6rem)] max-w-6xl items-center px-5 py-20 sm:min-h-[680px] sm:py-24">
|
||||
<div class="max-w-4xl text-white drop-shadow-[0_1px_18px_rgba(0,0,0,0.28)]">
|
||||
<h1 class="font-serif text-[2.5rem] font-medium leading-[1.02] tracking-[-0.02em] sm:text-6xl lg:text-7xl">
|
||||
Fresh
|
||||
<span class="italic text-white"> stories</span>,
|
||||
<span class="italic text-white"> guides</span>, and
|
||||
<span class="italic text-white"> ideas</span>.
|
||||
</h1>
|
||||
<p class="mt-8 max-w-xl text-lg leading-relaxed text-white/86">
|
||||
Your homepage hero copy goes here — describe what {SITE.name} offers and who it’s for. Edit it in app/src/pages/index.astro.
|
||||
</p>
|
||||
<div class="mt-10 flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||
<a
|
||||
href={`/blog/${featured.slug}`}
|
||||
class="group inline-flex items-center gap-2 border-b border-white pb-1 text-sm font-medium text-white hover:border-white/70 hover:text-white/82"
|
||||
>
|
||||
Begin with this issue's lead essay
|
||||
<Icon name="arrow-right" class="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
<a href="/blog" class="text-sm text-white/76 hover:text-white">
|
||||
Or browse the archive
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-border/60">
|
||||
<div class="mx-auto max-w-6xl px-5 py-16">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Featured</div>
|
||||
<a href="/blog" class="text-xs text-muted-foreground hover:text-foreground">All writing →</a>
|
||||
</div>
|
||||
<article class="grid gap-10 lg:grid-cols-2">
|
||||
<a href={`/blog/${featured.slug}`} class="group block" aria-label={`Read ${featured.title}`}>
|
||||
<div class="aspect-[4/3] overflow-hidden rounded-md bg-muted">
|
||||
{
|
||||
featured.thumbnail && (
|
||||
<Image
|
||||
src={featured.thumbnail}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{
|
||||
featuredCategory && (
|
||||
<a href={`/categories/${featuredCategory.slug}`} class="text-primary hover:underline">
|
||||
{featuredCategory.name}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<span>·</span>
|
||||
<time datetime={featured.date}>{formatDate(featured.date)}</time>
|
||||
</div>
|
||||
<a href={`/blog/${featured.slug}`}>
|
||||
<h2 class="mt-3 font-serif text-3xl font-semibold leading-tight tracking-tight hover:text-primary sm:text-4xl">
|
||||
{featured.title}
|
||||
</h2>
|
||||
</a>
|
||||
<p class="mt-4 text-lg text-muted-foreground">{featured.excerpt}</p>
|
||||
<div class="mt-6 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{
|
||||
featuredAuthor && (
|
||||
<>
|
||||
<img
|
||||
src={featuredAuthor.avatar}
|
||||
alt=""
|
||||
width="28"
|
||||
height="28"
|
||||
class="h-7 w-7 rounded-full"
|
||||
/>
|
||||
<a href={`/authors/${featuredAuthor.slug}`} class="hover:text-foreground">{featuredAuthor.name}</a>
|
||||
<span>·</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span>{featured.readingTime} min read</span>
|
||||
</div>
|
||||
<a href={`/blog/${featured.slug}`} class="mt-6 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
Read the essay <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-6xl px-5 py-16" data-home-latest>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 class="font-serif text-2xl font-semibold tracking-tight">Latest</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-home-filter=""
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors border-foreground bg-foreground text-background"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{
|
||||
categories.map((category) => (
|
||||
<button
|
||||
type="button"
|
||||
data-home-filter={category.slug}
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors border-border text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-home-empty class="mt-12 rounded-md border border-dashed border-border py-16 text-center text-muted-foreground" hidden>
|
||||
Nothing here yet in this section.
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{latest.map((post, index) => <PostCard post={post} hidden={index >= 6} />)}
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<a href="/blog" class="inline-flex items-center gap-2 rounded-md border border-border px-5 py-2.5 text-sm font-medium hover:bg-muted">
|
||||
Browse the archive <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Newsletter />
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const latest = document.querySelector("[data-home-latest]");
|
||||
if (latest) {
|
||||
const buttons = [...latest.querySelectorAll("[data-home-filter]")];
|
||||
const cards = [...latest.querySelectorAll("[data-post-card]")];
|
||||
const empty = latest.querySelector("[data-home-empty]");
|
||||
const activeClasses = ["border-foreground", "bg-foreground", "text-background"];
|
||||
const inactiveClasses = ["border-border", "text-muted-foreground", "hover:text-foreground"];
|
||||
|
||||
const setActive = (button, active) => {
|
||||
button.classList.toggle(activeClasses[0], active);
|
||||
button.classList.toggle(activeClasses[1], active);
|
||||
button.classList.toggle(activeClasses[2], active);
|
||||
inactiveClasses.forEach((className) => button.classList.toggle(className, !active));
|
||||
};
|
||||
|
||||
const filterCards = (category) => {
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const match = !category || card.dataset.category === category;
|
||||
const show = match && visible < 6;
|
||||
card.hidden = !show;
|
||||
if (match) visible += 1;
|
||||
});
|
||||
if (empty) empty.hidden = visible > 0;
|
||||
buttons.forEach((button) => setActive(button, button.dataset.homeFilter === category));
|
||||
};
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () => filterCards(button.dataset.homeFilter || ""));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,21 +1,62 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
// BaseLayout normally pulls this in. This page bypasses BaseLayout (the
|
||||
// component is a full-screen takeover and would fight the sticky header),
|
||||
// so the stylesheet has to be imported explicitly — without it none of the
|
||||
// component's Tailwind classes exist on the page at all.
|
||||
import "../styles.css";
|
||||
import NotFoundPage from "@/components/ui/page-not-found";
|
||||
import { SITE } from "../lib/blog-data.js";
|
||||
|
||||
// Deliberately NOT wrapped in BaseLayout: the component is a full-screen
|
||||
// takeover and would collide with the sticky header. Everything a visitor
|
||||
// needs in order to get out is inside the component itself.
|
||||
---
|
||||
|
||||
<BaseLayout title="Page not found" description="The page could not be found.">
|
||||
<div class="mx-auto max-w-2xl px-5 py-32 text-center">
|
||||
<div class="font-serif text-7xl font-semibold tracking-tight">404</div>
|
||||
<h1 class="mt-4 font-serif text-2xl font-semibold">Lost in the margins</h1>
|
||||
<p class="mt-3 text-muted-foreground">
|
||||
The page you're looking for has wandered off. It may have been moved, retitled, or never existed at all.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<a href="/" class="rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background hover:opacity-90">
|
||||
Back home
|
||||
</a>
|
||||
<a href="/blog" class="rounded-md border border-border px-4 py-2 text-sm hover:bg-muted">
|
||||
Browse the archive
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Page not found — {SITE.name}</title>
|
||||
<meta name="description" content="The page could not be found." />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter-latin.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<NotFoundPage client:load />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
/* The brand stylesheet sets body to washi paper; this page is a black
|
||||
takeover, so override it here. Declared after the import so it wins. */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000 !important;
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* The animation IS the page — the copy is black on black until the white
|
||||
circles wash over it. With reduced motion there are no circles, so flip
|
||||
the ground to white and skip the figures rather than leaving a visitor
|
||||
staring at an unreadable black screen. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
canvas,
|
||||
.characters {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,190 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Breadcrumbs from "../components/Breadcrumbs.astro";
|
||||
import { SITE, authors } from "../lib/blog-data.js";
|
||||
|
||||
const author = authors[0];
|
||||
|
||||
// The instinct that runs through everything — pulled out so the essay can stay
|
||||
// prose while the eye still catches the shape of the career.
|
||||
const timeline = [
|
||||
{ year: "1988", note: "Taught myself to code — Commodore BASIC, age 8" },
|
||||
{ year: "1990s", note: "Learning the craft: BASIC → Visual Basic → C++ → Java → the web" },
|
||||
{ year: "1999", note: "Building software professionally" },
|
||||
{ year: "2000s", note: "Built a CMS from scratch, before WordPress was a thing" },
|
||||
{ year: "2022", note: "All in on agentic AI" },
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AboutPage",
|
||||
mainEntity: {
|
||||
"@type": "Person",
|
||||
name: SITE.name,
|
||||
jobTitle: author.jobTitle,
|
||||
description: author.bio,
|
||||
url: SITE.url,
|
||||
sameAs: author.sameAs,
|
||||
},
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`About - ${SITE.name}`}
|
||||
description={`About ${SITE.name} — who we are and what we cover.`}
|
||||
title={`About — Agentic AI & Automation Engineer | ${SITE.name}`}
|
||||
description="Carlos Arias builds AI automation and autonomous agentic systems — replacing the manual workflows teams used to run by hand. 25+ years in software engineering, focused on agentic AI since 2022."
|
||||
canonical="/about"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<div class="mx-auto max-w-3xl px-5 py-12">
|
||||
<Breadcrumbs items={[{ label: "Home", to: "/" }, { label: "About" }]} />
|
||||
<!-- ---------- header ---------- -->
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-14 md:pt-28">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">About</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[18ch] font-serif text-[clamp(2.2rem,5.6vw,4.2rem)] leading-[1.04] tracking-tight text-balance"
|
||||
>
|
||||
I automate the work teams used to do by hand, with <span class="ca-brushed"
|
||||
>agentic AI.</span
|
||||
>
|
||||
</h1>
|
||||
<p class="mt-7 max-w-[54ch] font-serif text-[clamp(1.2rem,2.4vw,1.6rem)] leading-snug text-[var(--ca-ink-2)]">
|
||||
I’ve done this before it was obvious more than once. This time it feels like
|
||||
1988 again.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<header class="mt-6">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">About</div>
|
||||
<h1 class="mt-2 font-serif text-5xl font-semibold leading-[1.05] tracking-tight">
|
||||
About {SITE.name}.
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="prose-article mt-10">
|
||||
<p>
|
||||
This is placeholder copy for your About page. Describe what {SITE.name} is,
|
||||
who it’s for, and why it exists. Edit this in <code>app/src/pages/about.astro</code>.
|
||||
</p>
|
||||
<h2 id="what-we-do">What we do</h2>
|
||||
<p>
|
||||
Summarize what visitors will find here — the topics you cover, the value
|
||||
you offer, and what makes your site worth their time.
|
||||
</p>
|
||||
<h2 id="how-it-works">How it works</h2>
|
||||
<p>
|
||||
If your site uses the content pipeline or other automation, explain it here.
|
||||
Otherwise, use this space to describe your process, standards, or approach.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="mt-12 border-t border-border pt-10">
|
||||
<h2 class="font-serif text-2xl font-semibold tracking-tight">The people behind {SITE.name}</h2>
|
||||
<div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
authors.map((author) => (
|
||||
<a href={`/authors/${author.slug}`} class="group flex items-start gap-3">
|
||||
<img src={author.avatar} alt="" width="48" height="48" class="h-12 w-12 rounded-full" />
|
||||
<div>
|
||||
<div class="font-serif text-base font-semibold group-hover:text-primary">{author.name}</div>
|
||||
<div class="mt-1 text-sm text-muted-foreground">{author.bio}</div>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<!-- ---------- two columns: reel rail + essay ---------- -->
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pb-8">
|
||||
<div class="grid gap-12 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-16">
|
||||
<!-- left: sticky 9:16 Instagram placeholder -->
|
||||
<div class="ca-reel-col">
|
||||
<figure class="ca-reel" aria-label="Instagram — placeholder">
|
||||
<!-- Swap this block for a real Instagram embed (Reel iframe or oEmbed)
|
||||
when ready. The 9:16 frame reserves the exact space, so the embed
|
||||
drops in without reflowing the essay beside it. -->
|
||||
<div class="ca-reel-inner">
|
||||
<span class="ca-reel-glyph" aria-hidden="true">▶</span>
|
||||
<span class="ca-reel-label">Instagram</span>
|
||||
<span class="ca-reel-handle">@carlosarias.co</span>
|
||||
</div>
|
||||
<span class="ca-hero-seal" aria-hidden="true">印</span>
|
||||
</figure>
|
||||
<a
|
||||
href="https://instagram.com/carlosarias.co"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ca-quiet mt-4 inline-block font-mono text-[0.7rem] tracking-[0.12em] uppercase"
|
||||
>
|
||||
Follow along →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- right: the essay -->
|
||||
<div class="ca-prose ca-about-prose">
|
||||
<p>
|
||||
I taught myself to code in 1988, on a Commodore 64, at eight years old, using
|
||||
Commodore BASIC. What began as curiosity quickly became a lifelong obsession
|
||||
with technology, automation, and figuring out what computers could do next.
|
||||
I’ve been building software professionally since 1999.
|
||||
</p>
|
||||
<p>
|
||||
As technology evolved, so did I. I moved from BASIC to Visual Basic, C++, Java,
|
||||
and eventually web application development. By the late 1990s I was building
|
||||
database-driven applications and eCommerce systems from scratch. By the early
|
||||
2000s I had built my own content management system — before platforms like
|
||||
WordPress made CMS technology mainstream.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
That pattern has followed me my whole career: I see where technology is going
|
||||
and start building before it becomes obvious.
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
For more than 25 years I’ve worked across software engineering, product
|
||||
development, automation, digital marketing, and technology leadership.
|
||||
I’ve built SaaS platforms, founded technology companies, led engineering
|
||||
teams, and developed systems across FinTech, financial services, healthcare,
|
||||
digital marketing, and other industries.
|
||||
</p>
|
||||
|
||||
<h2>Today, the focus is agentic AI.</h2>
|
||||
<p>
|
||||
AI has brought me back to the feeling I had when I first discovered programming
|
||||
in 1988. I’ve worked with automation and intelligent software in different
|
||||
forms for years, but modern agentic AI fundamentally changed how I think about
|
||||
and build software. Since 2022 I’ve focused heavily on AI engineering,
|
||||
autonomous systems, and building intelligent agents that can reason, make
|
||||
decisions, use tools, coordinate workflows, and perform real work with minimal
|
||||
human intervention.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
Software that once needed a team and 6–12 months can now, in some cases,
|
||||
be designed and built by me in 6–12 days.
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
My work today goes beyond adding AI features or chatbots to traditional
|
||||
software. I build <strong>autonomous, agentic systems</strong> designed to
|
||||
actively perform work — systems that research, analyse information, make
|
||||
decisions, interact with APIs and software, generate and modify content,
|
||||
execute workflows, monitor results, and continuously adapt to what is happening
|
||||
around them.
|
||||
</p>
|
||||
<p>
|
||||
I’m particularly interested in building <strong>AI agents that replace
|
||||
workflows, not just individual tasks</strong> — turning complex business
|
||||
processes that traditionally need multiple people, tools, and repetitive manual
|
||||
work into intelligent systems that operate with increasing autonomy.
|
||||
</p>
|
||||
|
||||
<h2>This website is one of them.</h2>
|
||||
<p>
|
||||
It’s an <strong>agentic website</strong>. AI agents play an active role in
|
||||
its development, content, optimisation, maintenance, and evolution. I provide
|
||||
the vision, architecture, constraints, and strategic direction; AI performs much
|
||||
of the execution.
|
||||
</p>
|
||||
<p>
|
||||
In many ways, it feels like 1988 again. We’re at the beginning of a new era
|
||||
of computing. The tools are changing incredibly fast, the rules are still being
|
||||
written, and once again I find myself experimenting, building, and trying to
|
||||
understand what technology will be capable of next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- timeline strip ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-8 font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
The through-line
|
||||
</h2>
|
||||
<ol class="ca-timeline">
|
||||
{
|
||||
timeline.map((t) => (
|
||||
<li>
|
||||
<span class="ca-timeline-year">{t.year}</span>
|
||||
<span class="ca-timeline-note">{t.note}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ---------- closing ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2
|
||||
class="max-w-[20ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
Building the next thing, <span class="ca-brushed">before it’s obvious.</span>
|
||||
</h2>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Start a conversation
|
||||
</a>
|
||||
<a href="/projects" class="ca-quiet text-sm">See the work</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
82
app/src/pages/changelog.astro
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import { SITE } from "../lib/blog-data.js";
|
||||
import { getChangelogByDay } from "../lib/changelog.js";
|
||||
|
||||
const days = await getChangelogByDay();
|
||||
|
||||
const TYPE_LABEL = {
|
||||
added: "Added",
|
||||
updated: "Updated",
|
||||
fixed: "Fixed",
|
||||
bug: "Bug found",
|
||||
removed: "Removed",
|
||||
note: "Note",
|
||||
};
|
||||
|
||||
const time = (iso) => iso.slice(11, 16); // HH:MM
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
name: `Changelog — ${SITE.name}`,
|
||||
description: "A running log of how this site is built and changed.",
|
||||
url: `${SITE.url}/changelog`,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Changelog — ${SITE.name}`}
|
||||
description="A running, human-friendly log of how this site is built, updated, and fixed — an agentic website that documents its own evolution."
|
||||
canonical="/changelog"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<section class="mx-auto w-full max-w-3xl px-5 pt-20 pb-12 md:pt-28">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
Changelog
|
||||
</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[18ch] font-serif text-[clamp(2.2rem,5.6vw,4rem)] leading-[1.05] tracking-tight text-balance"
|
||||
>
|
||||
This site, <span class="ca-brushed">as it’s built.</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-[54ch] text-base leading-relaxed text-muted-foreground">
|
||||
An agentic website that documents its own evolution. Every change — added,
|
||||
updated, fixed, and the odd bug caught along the way — logged as it happens.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto w-full max-w-3xl px-5 pb-24">
|
||||
<div class="ca-log">
|
||||
{
|
||||
days.map((day) => (
|
||||
<div class="ca-log-group">
|
||||
<h2 class="ca-log-date">
|
||||
<time datetime={day.dateKey}>{day.dateLabel}</time>
|
||||
<span class="ca-log-count">{day.items.length}</span>
|
||||
</h2>
|
||||
<ol class="ca-log-list">
|
||||
{day.items.map((e) => (
|
||||
<li class="ca-log-entry">
|
||||
<span class="ca-log-node" data-type={e.type} aria-hidden="true" />
|
||||
<div class="ca-log-body">
|
||||
<div class="ca-log-head">
|
||||
<span class="ca-log-type" data-type={e.type}>
|
||||
{TYPE_LABEL[e.type] ?? e.type}
|
||||
</span>
|
||||
<time class="ca-log-time" datetime={e.entry_at}>
|
||||
{time(e.entry_at)}
|
||||
</time>
|
||||
</div>
|
||||
<p class="ca-log-summary">{e.summary}</p>
|
||||
{e.detail && <p class="ca-log-detail">{e.detail}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,98 +1,205 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Breadcrumbs from "../components/Breadcrumbs.astro";
|
||||
import Icon from "../components/Icon.astro";
|
||||
import { SITE } from "../lib/blog-data.js";
|
||||
import { SITE, authors } from "../lib/blog-data.js";
|
||||
|
||||
const author = authors[0];
|
||||
|
||||
// Kept in sync with SUBJECTS in api/public/controllers/contact.php. The server
|
||||
// is authoritative — an unknown slug is coerced to "other" there — so a drift
|
||||
// here degrades gracefully rather than breaking.
|
||||
const subjects = [
|
||||
{ value: "automation", label: "Automation" },
|
||||
{ value: "website-design", label: "Website Design" },
|
||||
{ value: "digital-marketing", label: "Digital Marketing" },
|
||||
{ value: "fractional-cto", label: "Fractional CTO" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Contact - ${SITE.name}`}
|
||||
description={`Get in touch with ${SITE.name}.`}
|
||||
title={`Contact — ${SITE.name}`}
|
||||
description="Start a conversation about agentic AI, automation, or a fractional CTO engagement."
|
||||
canonical="/contact"
|
||||
>
|
||||
<div class="mx-auto max-w-3xl px-5 py-12">
|
||||
<Breadcrumbs items={[{ label: "Home", to: "/" }, { label: "Contact" }]} />
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-24 md:pt-28">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
{author.jobTitle}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[16ch] font-serif text-[clamp(2.4rem,6vw,4rem)] leading-[1.05] tracking-tight text-balance"
|
||||
>
|
||||
Let’s build something that <span class="ca-brushed">runs itself.</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-[54ch] text-base leading-relaxed text-muted-foreground">
|
||||
Automation, an autonomous system, or a fractional CTO engagement — tell me what
|
||||
you’re working on and I’ll get back to you.
|
||||
</p>
|
||||
|
||||
<header class="mt-6">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">Contact</div>
|
||||
<h1 class="mt-2 font-serif text-5xl font-semibold tracking-tight">Say hello.</h1>
|
||||
<p class="mt-4 max-w-xl text-muted-foreground">
|
||||
A tip, a correction, or a question. We read everything and reply to most of it.
|
||||
</p>
|
||||
</header>
|
||||
<div class="mt-14 grid gap-14 lg:grid-cols-[1.4fr_0.6fr]">
|
||||
<!-- ---------- form ---------- -->
|
||||
<form data-contact class="ca-form" novalidate>
|
||||
<div class="ca-field">
|
||||
<label for="cf-name">Name</label>
|
||||
<input id="cf-name" name="name" type="text" autocomplete="name" required maxlength="160" />
|
||||
<p class="ca-field-error" data-error="name" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-12 lg:grid-cols-[1fr_240px]">
|
||||
<form data-contact-form class="space-y-5">
|
||||
<div>
|
||||
<label for="name" class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Name</label>
|
||||
<input id="name" required class="mt-2 w-full border-0 border-b border-input bg-transparent py-2 text-base outline-none focus:border-primary" />
|
||||
<div class="ca-field">
|
||||
<label for="cf-email">Email</label>
|
||||
<input id="cf-email" name="email" type="email" autocomplete="email" required maxlength="255" />
|
||||
<p class="ca-field-error" data-error="email" hidden></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Email</label>
|
||||
<input id="email" type="email" required class="mt-2 w-full border-0 border-b border-input bg-transparent py-2 text-base outline-none focus:border-primary" />
|
||||
|
||||
<div class="ca-field">
|
||||
<label for="cf-subject">What’s this about?</label>
|
||||
<div class="ca-select">
|
||||
<select id="cf-subject" name="subject" required>
|
||||
{subjects.map((s) => <option value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<span class="ca-select-caret" aria-hidden="true">↓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="message" class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Message</label>
|
||||
<textarea id="message" required rows="6" class="mt-2 w-full border-0 border-b border-input bg-transparent py-2 text-base outline-none focus:border-primary"></textarea>
|
||||
|
||||
<div class="ca-field">
|
||||
<label for="cf-message">Message</label>
|
||||
<textarea id="cf-message" name="message" rows="6" required maxlength="5000"></textarea>
|
||||
<p class="ca-field-error" data-error="message" hidden></p>
|
||||
</div>
|
||||
<div aria-hidden="true" style="position:absolute;left:-9999px;height:0;overflow:hidden">
|
||||
<label>Website<input id="website" name="website" tabindex="-1" autocomplete="off" /></label>
|
||||
|
||||
<!-- Honeypot: hidden from people, catnip for bots. Positioned off-screen
|
||||
rather than display:none so headless bots that skip hidden fields
|
||||
still fill it. Never type in this. -->
|
||||
<div class="ca-honeypot" aria-hidden="true">
|
||||
<label for="cf-company">Company</label>
|
||||
<input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="ca-form-foot">
|
||||
<button type="submit" class="ca-submit" data-submit>Send message</button>
|
||||
<p class="ca-form-status" data-status role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<button type="submit" class="rounded-md bg-foreground px-6 py-2.5 text-sm font-medium text-background hover:opacity-90">
|
||||
Send
|
||||
</button>
|
||||
<p data-contact-sent class="text-sm text-primary" role="status" hidden>Thanks — we'll be in touch.</p>
|
||||
</form>
|
||||
|
||||
<aside class="space-y-6 border-l border-border pl-8 lg:block">
|
||||
<!-- ---------- aside ---------- -->
|
||||
<aside class="flex flex-col gap-8">
|
||||
<div>
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Elsewhere</div>
|
||||
<ul class="mt-3 space-y-3 text-sm">
|
||||
<li><a href="https://x.com/" class="flex items-center gap-2 hover:text-primary"><Icon name="twitter" class="h-3.5 w-3.5" /> @yourhandle</a></li>
|
||||
<li><a href="/rss.xml" class="flex items-center gap-2 hover:text-primary"><Icon name="rss" class="h-3.5 w-3.5" /> RSS feed</a></li>
|
||||
</ul>
|
||||
<h2 class="font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
Direct
|
||||
</h2>
|
||||
<a href="mailto:hi@carlosarias.com" class="ca-quiet mt-3 inline-block text-lg">
|
||||
hi@carlosarias.com
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Tips</div>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Know a new opening or an underrated spot? Send it our way — we factor reader tips into our guides and news roundups.</p>
|
||||
<h2 class="font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
Elsewhere
|
||||
</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{
|
||||
(author.sameAs ?? []).map((href) => {
|
||||
const host = new URL(href).hostname.replace(/^www\./, "");
|
||||
const label = host.split(".")[0];
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ca-quiet text-sm capitalize"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const contactForm = document.querySelector("[data-contact-form]");
|
||||
const sent = document.querySelector("[data-contact-sent]");
|
||||
contactForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const btn = contactForm.querySelector("button[type=submit]");
|
||||
const payload = {
|
||||
name: document.getElementById("name").value.trim(),
|
||||
email: document.getElementById("email").value.trim(),
|
||||
message: document.getElementById("message").value.trim(),
|
||||
website: document.getElementById("website")?.value || "",
|
||||
};
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Sending…";
|
||||
const form = document.querySelector("[data-contact]");
|
||||
if (form) {
|
||||
// Deep-link support: /contact?subject=fractional-cto preselects the
|
||||
// dropdown. The footer's Services links use this, so a visitor arrives with
|
||||
// the right enquiry already chosen. Unknown values are ignored (the select
|
||||
// keeps its default), and the server coerces anything odd to "other".
|
||||
const wanted = new URLSearchParams(location.search).get("subject");
|
||||
if (wanted) {
|
||||
const select = form.querySelector("#cf-subject");
|
||||
const match = [...select.options].some((o) => o.value === wanted);
|
||||
if (match) select.value = wanted;
|
||||
}
|
||||
try {
|
||||
const r = await fetch("/contact-submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || "Something went wrong. Please try again.");
|
||||
contactForm.reset();
|
||||
if (sent) sent.hidden = false;
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Send";
|
||||
|
||||
const status = form.querySelector("[data-status]");
|
||||
const submit = form.querySelector("[data-submit]");
|
||||
const errorEls = [...form.querySelectorAll("[data-error]")];
|
||||
|
||||
const clearErrors = () => {
|
||||
for (const el of errorEls) {
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
status.textContent = "";
|
||||
status.dataset.state = "";
|
||||
};
|
||||
|
||||
const showFieldErrors = (fields) => {
|
||||
for (const el of errorEls) {
|
||||
const msg = fields?.[el.dataset.error];
|
||||
if (msg) {
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Sending…";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/contact/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && body.ok) {
|
||||
form.reset();
|
||||
status.textContent = body.data?.message || "Thanks — I'll be in touch shortly.";
|
||||
status.dataset.state = "ok";
|
||||
submit.textContent = "Sent";
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 422 && body.error?.fields) {
|
||||
showFieldErrors(body.error.fields);
|
||||
status.textContent = body.error.message || "Please check the highlighted fields.";
|
||||
} else if (res.status === 429) {
|
||||
status.textContent =
|
||||
"Too many messages just now — please try again in a few minutes.";
|
||||
} else {
|
||||
status.textContent =
|
||||
"Something went wrong sending that. Please email hi@carlosarias.com.";
|
||||
}
|
||||
status.dataset.state = "error";
|
||||
} catch {
|
||||
status.textContent =
|
||||
"Couldn't reach the server. Please email hi@carlosarias.com instead.";
|
||||
status.dataset.state = "error";
|
||||
} finally {
|
||||
if (submit.textContent === "Sending…") submit.textContent = "Send message";
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,225 +1,249 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import Newsletter from "../components/Newsletter.astro";
|
||||
import Icon from "../components/Icon.astro";
|
||||
import heroImage from "../assets/hero-placeholder.png";
|
||||
import {
|
||||
SITE,
|
||||
categories,
|
||||
featuredPost,
|
||||
sortedPosts,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
formatDate,
|
||||
organizationSchema,
|
||||
websiteSchema,
|
||||
} from "../lib/blog-data.js";
|
||||
import BrandMark from "../components/BrandMark.astro";
|
||||
import InkWash from "../components/InkWash.astro";
|
||||
import IndexRow from "../components/IndexRow.astro";
|
||||
import SectionHead from "../components/SectionHead.astro";
|
||||
import { SITE, authors, personSchema } from "../lib/blog-data.js";
|
||||
|
||||
const featured = await featuredPost();
|
||||
const featuredAuthor = getAuthor(featured.author);
|
||||
const featuredCategory = getCategory(featured.category);
|
||||
const latest = (await sortedPosts()).filter((post) => post.slug !== featured.slug);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [organizationSchema(), websiteSchema()],
|
||||
};
|
||||
// The theme exposes the author via `authors`, not on SITE.
|
||||
const author = authors[0];
|
||||
import projects from "../config/projects.json";
|
||||
import capabilities from "../config/capabilities.json";
|
||||
|
||||
const concepts = [
|
||||
"Agentic websites",
|
||||
"Autonomous SEO systems",
|
||||
"Multi-agent collaboration",
|
||||
"AI product architecture",
|
||||
"Retrieval-augmented generation",
|
||||
"AI knowledge systems",
|
||||
"AI workflow orchestration",
|
||||
"AI-driven software development",
|
||||
"Self-healing applications",
|
||||
"Autonomous business systems",
|
||||
];
|
||||
|
||||
const industries = [
|
||||
"Artificial Intelligence",
|
||||
"SaaS",
|
||||
"FinTech",
|
||||
"Healthcare",
|
||||
"Legal Technology",
|
||||
"Digital Marketing",
|
||||
"Location Intelligence",
|
||||
"Automation Platforms",
|
||||
];
|
||||
|
||||
const facts = [
|
||||
{ label: "Writing code since", value: "1988 — Commodore 64, age 8" },
|
||||
{ label: "Professional experience", value: "26 years" },
|
||||
{ label: "FinTech since", value: "2014" },
|
||||
{ label: "Compliance", value: "HIPAA · SOC 2 · PCI" },
|
||||
{ label: "Focused on agentic AI since", value: "2022" },
|
||||
];
|
||||
|
||||
const jsonLd = { "@context": "https://schema.org", ...personSchema(author) };
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${SITE.name} — ${SITE.tagline}`}
|
||||
title={`${SITE.name} — ${author.jobTitle}`}
|
||||
description={SITE.description}
|
||||
canonical="/"
|
||||
jsonLd={jsonLd}
|
||||
flushFooter
|
||||
>
|
||||
<section class="relative -mt-[calc(4rem+2px)] min-h-[calc(100svh-2rem)] overflow-hidden border-b border-border/60 pt-[calc(4rem+2px)] sm:min-h-[760px]">
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt="Hero image placeholder — replace in app/src/assets"
|
||||
class="absolute inset-0 h-full w-full object-cover object-center"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
widths={[640, 960, 1280, 1920, 2560]}
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 bg-[linear-gradient(90deg,oklch(0.1_0.02_70_/_0.68),oklch(0.16_0.02_80_/_0.38)_46%,oklch(0.98_0.01_90_/_0.1))] dark:bg-[linear-gradient(90deg,oklch(0.08_0.01_70_/_0.78),oklch(0.08_0.01_70_/_0.52)_48%,oklch(0.08_0.01_70_/_0.18))]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/45 via-black/18 to-transparent"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/72 to-transparent"
|
||||
/>
|
||||
<div class="relative mx-auto flex min-h-[calc(100svh-6rem)] max-w-6xl items-center px-5 py-20 sm:min-h-[680px] sm:py-24">
|
||||
<div class="max-w-4xl text-white drop-shadow-[0_1px_18px_rgba(0,0,0,0.28)]">
|
||||
<h1 class="font-serif text-[2.5rem] font-medium leading-[1.02] tracking-[-0.02em] sm:text-6xl lg:text-7xl">
|
||||
Fresh
|
||||
<span class="italic text-white"> stories</span>,
|
||||
<span class="italic text-white"> guides</span>, and
|
||||
<span class="italic text-white"> ideas</span>.
|
||||
</h1>
|
||||
<p class="mt-8 max-w-xl text-lg leading-relaxed text-white/86">
|
||||
Your homepage hero copy goes here — describe what {SITE.name} offers and who it’s for. Edit it in app/src/pages/index.astro.
|
||||
<!-- hero: the one ink surface on the page -->
|
||||
<section
|
||||
class="ca-ink-surface relative -mt-16 flex min-h-[92svh] flex-col justify-center pt-32 pb-16"
|
||||
>
|
||||
<InkWash />
|
||||
<div class="relative z-10 mx-auto w-full max-w-6xl px-5">
|
||||
<div class="grid items-center gap-10 lg:grid-cols-[1.4fr_0.6fr] lg:gap-16">
|
||||
<div>
|
||||
<h1 class="mb-8 md:mb-11">
|
||||
<span class="sr-only">{SITE.name} — {author.jobTitle}</span>
|
||||
<BrandMark width="min(34rem, 100%)" animate invert seal={false} />
|
||||
</h1>
|
||||
|
||||
<div class="ca-seq">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
{author.jobTitle}
|
||||
</p>
|
||||
<div class="mt-10 flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||
<a
|
||||
href={`/blog/${featured.slug}`}
|
||||
class="group inline-flex items-center gap-2 border-b border-white pb-1 text-sm font-medium text-white hover:border-white/70 hover:text-white/82"
|
||||
>
|
||||
Begin with this issue's lead essay
|
||||
<Icon name="arrow-right" class="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
<a href="/blog" class="text-sm text-white/76 hover:text-white">
|
||||
Or browse the archive
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-border/60">
|
||||
<div class="mx-auto max-w-6xl px-5 py-16">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Featured</div>
|
||||
<a href="/blog" class="text-xs text-muted-foreground hover:text-foreground">All writing →</a>
|
||||
</div>
|
||||
<article class="grid gap-10 lg:grid-cols-2">
|
||||
<a href={`/blog/${featured.slug}`} class="group block" aria-label={`Read ${featured.title}`}>
|
||||
<div class="aspect-[4/3] overflow-hidden rounded-md bg-muted">
|
||||
{
|
||||
featured.thumbnail && (
|
||||
<Image
|
||||
src={featured.thumbnail}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{
|
||||
featuredCategory && (
|
||||
<a href={`/categories/${featuredCategory.slug}`} class="text-primary hover:underline">
|
||||
{featuredCategory.name}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<span>·</span>
|
||||
<time datetime={featured.date}>{formatDate(featured.date)}</time>
|
||||
</div>
|
||||
<a href={`/blog/${featured.slug}`}>
|
||||
<h2 class="mt-3 font-serif text-3xl font-semibold leading-tight tracking-tight hover:text-primary sm:text-4xl">
|
||||
{featured.title}
|
||||
</h2>
|
||||
</a>
|
||||
<p class="mt-4 text-lg text-muted-foreground">{featured.excerpt}</p>
|
||||
<div class="mt-6 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{
|
||||
featuredAuthor && (
|
||||
<>
|
||||
<img
|
||||
src={featuredAuthor.avatar}
|
||||
alt=""
|
||||
width="28"
|
||||
height="28"
|
||||
class="h-7 w-7 rounded-full"
|
||||
/>
|
||||
<a href={`/authors/${featuredAuthor.slug}`} class="hover:text-foreground">{featuredAuthor.name}</a>
|
||||
<span>·</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span>{featured.readingTime} min read</span>
|
||||
</div>
|
||||
<a href={`/blog/${featured.slug}`} class="mt-6 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
Read the essay <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-6xl px-5 py-16" data-home-latest>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 class="font-serif text-2xl font-semibold tracking-tight">Latest</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-home-filter=""
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors border-foreground bg-foreground text-background"
|
||||
<p
|
||||
class="mt-6 max-w-[24ch] font-serif text-[clamp(1.6rem,3.2vw,2.4rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
Autonomous systems that do the work teams used to do <span class="ca-brushed"
|
||||
>by hand.</span
|
||||
>
|
||||
</p>
|
||||
<p class="mt-6 max-w-[52ch] text-base leading-relaxed text-muted-foreground">
|
||||
I design and build AI agents and intelligent business systems that automate
|
||||
complex workflows end to end. 26 years as a software engineer — since 2022,
|
||||
focused entirely on agentic AI.
|
||||
</p>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Let’s build your autonomous system →
|
||||
</a>
|
||||
<a href="#work" class="ca-quiet text-sm">See the work</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The hero is an ink surface in both themes, so this always uses the
|
||||
dark-wardrobe frame — the black henley and the bar behind it melt
|
||||
into the ground instead of sitting in a box. The radial mask does
|
||||
the rest: no visible edge where the photograph stops. -->
|
||||
<figure class="ca-hero-portrait ca-seq" style="--ca-seq-delay: 1.62s">
|
||||
<img
|
||||
src="/carlos-arias-dark.jpg"
|
||||
alt={`${SITE.name}, ${author.jobTitle}`}
|
||||
width="760"
|
||||
height="950"
|
||||
loading="eager"
|
||||
/>
|
||||
<span class="ca-hero-seal" aria-hidden="true">印</span>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
<!-- kintsugi at the seam where ink meets paper -->
|
||||
<span class="absolute bottom-[-1px] left-[12%] z-10 h-0.5 w-11 bg-[var(--ca-seal)]"></span>
|
||||
</section>
|
||||
|
||||
<!-- capabilities, not services: signals builder rather than agency -->
|
||||
<section id="expertise" class="ca-band">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<SectionHead title="What I build" meta="Capabilities" brush />
|
||||
<div class="grid gap-x-14 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<button
|
||||
type="button"
|
||||
data-home-filter={category.slug}
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors border-border text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
capabilities.map((cap) => (
|
||||
<div class="border-t border-border py-6">
|
||||
<h3 class="font-serif text-2xl leading-snug tracking-tight">{cap.title}</h3>
|
||||
<p class="mt-2 max-w-[44ch] text-sm text-muted-foreground">{cap.summary}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div data-home-empty class="mt-12 rounded-md border border-dashed border-border py-16 text-center text-muted-foreground" hidden>
|
||||
Nothing here yet in this section.
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{latest.map((post, index) => <PostCard post={post} hidden={index >= 6} />)}
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<a href="/blog" class="inline-flex items-center gap-2 rounded-md border border-border px-5 py-2.5 text-sm font-medium hover:bg-muted">
|
||||
Browse the archive <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<!-- work as a typographic index, not cards -->
|
||||
<section id="work" class="ca-band">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<SectionHead title="Current work" meta="2014 — present" brush />
|
||||
<div class="flex flex-col">
|
||||
{
|
||||
projects.map((project, i) => (
|
||||
<IndexRow
|
||||
href={`/projects/${project.slug}`}
|
||||
title={project.title}
|
||||
meta={project.period}
|
||||
summary={project.summary}
|
||||
first={i === 0}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="mt-9">
|
||||
<a href="/projects" class="ca-quiet font-mono text-xs tracking-[0.12em] uppercase"
|
||||
>All projects</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Newsletter />
|
||||
<!-- the one full-bleed vermillion moment. The 1-2% seal rule is
|
||||
absolute everywhere else, which is what lets this land. -->
|
||||
<section class="bg-[var(--ca-seal)] py-20 text-white md:py-32">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<p
|
||||
class="max-w-[17ch] font-serif text-[clamp(2rem,6vw,4.2rem)] leading-[1.1] tracking-tight text-balance"
|
||||
>
|
||||
Build software that builds, improves, and manages software.
|
||||
</p>
|
||||
<span class="mt-8 block font-mono text-xs tracking-[0.16em] uppercase opacity-70"
|
||||
>The mission</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" class="ca-band">
|
||||
<div class="mx-auto grid w-full max-w-6xl gap-12 px-5 lg:grid-cols-[1.55fr_1fr] lg:gap-20">
|
||||
<div class="text-[var(--ca-ink-2)]">
|
||||
<p class="mb-5 max-w-[62ch]">
|
||||
I’ve been writing software since I was eight years old, on a Commodore 64 in
|
||||
1988. Nearly four decades later the instinct is unchanged: if a process is
|
||||
repetitive, it should build and run itself.
|
||||
</p>
|
||||
<p class="mb-5 max-w-[62ch]">
|
||||
For 26 years I’ve worked as a Software Engineer, CTO, Product Engineer, and
|
||||
Technology Advisor across startups and enterprise — leading engineering teams
|
||||
and shipping platforms in FinTech, healthcare, SaaS, digital marketing, and
|
||||
geospatial technology. In FinTech since 2014, which means security and compliance
|
||||
aren’t an afterthought: HIPAA, SOC 2, PCI.
|
||||
</p>
|
||||
<p class="max-w-[62ch]">
|
||||
Since 2022 my focus has been singular. I don’t bolt AI onto existing
|
||||
applications — I design systems where multiple agents collaborate:
|
||||
researching, planning, deciding, generating content, writing software, running QA,
|
||||
and optimizing continuously with minimal human intervention.
|
||||
</p>
|
||||
</div>
|
||||
<dl class="flex flex-col self-start">
|
||||
{
|
||||
facts.map((fact, i) => (
|
||||
<div class={`border-b border-border py-4 ${i === 0 ? "border-t" : ""}`}>
|
||||
<dt class="font-mono text-xs tracking-[0.1em] text-[var(--ca-ink-4)] uppercase">
|
||||
{fact.label}
|
||||
</dt>
|
||||
<dd class="mt-1 font-serif text-xl leading-snug">{fact.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ca-band">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<SectionHead title="Research & concepts" meta="Ongoing" />
|
||||
<ul class="flex max-w-4xl flex-wrap gap-2">
|
||||
{
|
||||
concepts.map((concept) => (
|
||||
<li class="rounded-[var(--radius)] border border-border px-3 py-1.5 font-mono text-xs tracking-wide text-muted-foreground transition-colors hover:border-[var(--ca-ink-3)] hover:text-foreground">
|
||||
{concept}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ca-band">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-8 font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
Industries
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-4xl font-mono text-xs leading-loose tracking-[0.1em] text-[var(--ca-ink-4)] uppercase"
|
||||
>
|
||||
{industries.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="ca-band">
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<h2
|
||||
class="max-w-[18ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
Automation that actually <span class="ca-brushed">runs itself.</span>
|
||||
</h2>
|
||||
<a href="/contact" class="ca-quiet mt-9 inline-block text-xl">Start a conversation</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const latest = document.querySelector("[data-home-latest]");
|
||||
if (latest) {
|
||||
const buttons = [...latest.querySelectorAll("[data-home-filter]")];
|
||||
const cards = [...latest.querySelectorAll("[data-post-card]")];
|
||||
const empty = latest.querySelector("[data-home-empty]");
|
||||
const activeClasses = ["border-foreground", "bg-foreground", "text-background"];
|
||||
const inactiveClasses = ["border-border", "text-muted-foreground", "hover:text-foreground"];
|
||||
|
||||
const setActive = (button, active) => {
|
||||
button.classList.toggle(activeClasses[0], active);
|
||||
button.classList.toggle(activeClasses[1], active);
|
||||
button.classList.toggle(activeClasses[2], active);
|
||||
inactiveClasses.forEach((className) => button.classList.toggle(className, !active));
|
||||
};
|
||||
|
||||
const filterCards = (category) => {
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const match = !category || card.dataset.category === category;
|
||||
const show = match && visible < 6;
|
||||
card.hidden = !show;
|
||||
if (match) visible += 1;
|
||||
});
|
||||
if (empty) empty.hidden = visible > 0;
|
||||
buttons.forEach((button) => setActive(button, button.dataset.homeFilter === category));
|
||||
};
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () => filterCards(button.dataset.homeFilter || ""));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
369
app/src/pages/projects/[slug].astro
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
---
|
||||
import { marked } from "marked";
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import IndexRow from "../../components/IndexRow.astro";
|
||||
import { SITE } from "../../lib/blog-data.js";
|
||||
import { getProjects } from "../../lib/projects.js";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getProjects();
|
||||
return projects.map((project) => ({
|
||||
params: { slug: project.slug },
|
||||
props: { project, projects },
|
||||
}));
|
||||
}
|
||||
|
||||
const { project, projects } = Astro.props;
|
||||
|
||||
const body = project.body ? marked.parse(project.body) : "";
|
||||
|
||||
// Everything else, so a visitor who reaches the end has somewhere to go.
|
||||
const others = projects.filter((p) => p.slug !== project.slug).slice(0, 3);
|
||||
|
||||
const igHandle = project.links?.instagram
|
||||
? "@" + project.links.instagram.replace(/^.*instagram\.com\//, "").replace(/\/$/, "")
|
||||
: null;
|
||||
|
||||
const statusLabel = {
|
||||
live: "Live",
|
||||
building: "In development",
|
||||
archived: "Archived",
|
||||
concept: "Concept",
|
||||
}[project.status] ?? project.status;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CreativeWork",
|
||||
name: project.title,
|
||||
description: project.summary,
|
||||
url: `${SITE.url}/projects/${project.slug}`,
|
||||
author: { "@type": "Person", name: SITE.name, url: SITE.url },
|
||||
keywords: (project.categories ?? []).join(", "),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={project.seoTitle ?? `${project.title} — ${SITE.name}`}
|
||||
description={project.seoDescription ?? project.summary}
|
||||
canonical={`/projects/${project.slug}`}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<article>
|
||||
<!-- ---------- header ---------- -->
|
||||
<header class="mx-auto w-full max-w-6xl px-5 pt-16 pb-12 md:pt-24">
|
||||
<a href="/projects" class="ca-back">
|
||||
<span aria-hidden="true">←</span> All projects
|
||||
</a>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center gap-3">
|
||||
<span class:list={["ca-status", `is-${project.status}`]}>{statusLabel}</span>
|
||||
{
|
||||
(project.categories ?? []).map((c) => (
|
||||
<a href={`/projects#${c.toLowerCase()}`} class="ca-chip">
|
||||
{c}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="mt-6 max-w-[16ch] font-serif text-[clamp(2.4rem,6vw,4rem)] leading-[1.05] tracking-tight text-balance"
|
||||
>
|
||||
{project.title}
|
||||
</h1>
|
||||
|
||||
{
|
||||
project.lede && (
|
||||
<p class="mt-6 max-w-[52ch] font-serif text-[clamp(1.2rem,2.4vw,1.6rem)] leading-snug text-[var(--ca-ink-2)]">
|
||||
{project.lede}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- ---------- facts rail ---------- -->
|
||||
<section class="mx-auto w-full max-w-6xl px-5">
|
||||
<dl class="ca-facts">
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dd>{project.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>{project.period}</dd>
|
||||
</div>
|
||||
{
|
||||
project.role && (
|
||||
<div>
|
||||
<dt>Role</dt>
|
||||
<dd>{project.role}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
project.links?.live && (
|
||||
<div>
|
||||
<dt>Live</dt>
|
||||
<dd>
|
||||
<a href={project.links.live} target="_blank" rel="noopener noreferrer" class="ca-quiet">
|
||||
{project.links.live.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
project.links?.instagram && (
|
||||
<div>
|
||||
<dt>Instagram</dt>
|
||||
<dd>
|
||||
<a href={project.links.instagram} target="_blank" rel="noopener noreferrer" class="ca-quiet">
|
||||
@{project.links.instagram.replace(/^.*instagram\.com\//, "").replace(/\/$/, "")}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
project.links?.github && (
|
||||
<div>
|
||||
<dt>Code</dt>
|
||||
<dd>
|
||||
<a href={project.links.github} target="_blank" rel="noopener noreferrer" class="ca-quiet">
|
||||
{project.links.github.replace(/^https?:\/\/(www\.)?github\.com\//, "").replace(/\/$/, "")}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ---------- metrics ---------- -->
|
||||
{
|
||||
(project.metrics ?? []).length > 0 && (
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-14">
|
||||
<div class="ca-metrics">
|
||||
{project.metrics.map((m) => (
|
||||
<div class="ca-metric">
|
||||
<div class="ca-metric-value">{m.value}</div>
|
||||
<div class="ca-metric-label">{m.label}</div>
|
||||
{m.note && <div class="ca-metric-note">{m.note}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- ---------- gallery ---------- -->
|
||||
{
|
||||
(project.gallery ?? []).length > 0 && (
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-16 md:pt-20">
|
||||
<h2 class="mb-6 font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
Gallery
|
||||
</h2>
|
||||
<div class="ca-gallery" data-gallery>
|
||||
{project.gallery.map((img, i) =>
|
||||
img.src ? (
|
||||
<button
|
||||
type="button"
|
||||
class="ca-gallery-item"
|
||||
data-gallery-open
|
||||
data-src={img.src}
|
||||
data-caption={img.caption ?? ""}
|
||||
aria-label={`Enlarge: ${img.caption ?? img.alt ?? `image ${i + 1}`}`}
|
||||
>
|
||||
<img src={img.src} alt={img.alt ?? ""} loading="lazy" />
|
||||
{img.caption && <span class="ca-gallery-cap">{img.caption}</span>}
|
||||
</button>
|
||||
) : (
|
||||
<div class="ca-gallery-item is-empty" aria-hidden="true">
|
||||
<span class="ca-gallery-glyph">+</span>
|
||||
{img.caption && <span class="ca-gallery-cap">{img.caption}</span>}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- ---------- body + social rail ---------- -->
|
||||
{
|
||||
body && (
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-16 md:pt-20">
|
||||
<div class="ca-body-grid">
|
||||
<!-- left: sticky Instagram 9:16 placeholder -->
|
||||
<aside class="ca-body-rail">
|
||||
<figure class="ca-reel" aria-label="Instagram — placeholder">
|
||||
{/* Swap for a real Instagram Reel embed when ready. The 9:16
|
||||
frame reserves the space, so the embed drops in without
|
||||
reflowing the case study beside it. */}
|
||||
<div class="ca-reel-inner">
|
||||
<span class="ca-reel-glyph" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
<span class="ca-reel-label">Instagram</span>
|
||||
{igHandle && <span class="ca-reel-handle">{igHandle}</span>}
|
||||
</div>
|
||||
<span class="ca-hero-seal" aria-hidden="true">
|
||||
印
|
||||
</span>
|
||||
</figure>
|
||||
{project.links?.instagram && (
|
||||
<a
|
||||
href={project.links.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ca-quiet mt-4 inline-block font-mono text-[0.7rem] tracking-[0.12em] uppercase"
|
||||
>
|
||||
Watch on Instagram →
|
||||
</a>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<!-- right: the case study -->
|
||||
<div class="ca-body-main ca-prose" set:html={body} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- ---------- skills applied (disciplines) ---------- -->
|
||||
{
|
||||
(project.skills ?? []).length > 0 && (
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-8 font-serif text-[clamp(1.6rem,3.4vw,2.1rem)] leading-tight tracking-tight">
|
||||
<span class="ca-brushed">Skills applied</span>
|
||||
</h2>
|
||||
<div class="ca-skills">
|
||||
{project.skills.map((group) => (
|
||||
<div class="ca-skill-group">
|
||||
<h3>{group.group}</h3>
|
||||
<ul>
|
||||
{(group.items ?? []).map((item) => (
|
||||
<li>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- ---------- built with (tech stack) ---------- -->
|
||||
{
|
||||
(project.stack ?? []).length > 0 && (
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-4 pb-2">
|
||||
<h2 class="mb-4 font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
Built with
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{project.stack.map((s) => (
|
||||
<li class="rounded-[var(--radius)] border border-border px-2.5 py-1 font-mono text-[0.7rem] tracking-wide text-muted-foreground">
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- ---------- next ---------- -->
|
||||
{
|
||||
others.length > 0 && (
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-8 font-serif text-[clamp(1.6rem,3.4vw,2.1rem)] leading-tight tracking-tight">
|
||||
<span class="ca-brushed">More work</span>
|
||||
</h2>
|
||||
<div class="flex flex-col">
|
||||
{others.map((p, i) => (
|
||||
<IndexRow
|
||||
href={`/projects/${p.slug}`}
|
||||
title={p.title}
|
||||
meta={p.period}
|
||||
summary={p.summary}
|
||||
tags={p.categories}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2
|
||||
class="max-w-[20ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
Building something <span class="ca-brushed">like this?</span>
|
||||
</h2>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Start a conversation
|
||||
</a>
|
||||
<a href="/projects" class="ca-quiet text-sm">See all projects</a>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- Lightbox for the gallery. One overlay, reused for every image; only
|
||||
renders if the page has a gallery with real (non-placeholder) photos. -->
|
||||
<div class="ca-lightbox" data-lightbox hidden>
|
||||
<button type="button" class="ca-lightbox-close" data-lightbox-close aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
<figure class="ca-lightbox-fig">
|
||||
<img data-lightbox-img src="" alt="" />
|
||||
<figcaption data-lightbox-cap></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const openers = document.querySelectorAll("[data-gallery-open]");
|
||||
const box = document.querySelector("[data-lightbox]");
|
||||
|
||||
if (openers.length && box) {
|
||||
const img = box.querySelector("[data-lightbox-img]");
|
||||
const cap = box.querySelector("[data-lightbox-cap]");
|
||||
const closeBtn = box.querySelector("[data-lightbox-close]");
|
||||
let lastFocus = null;
|
||||
|
||||
const open = (src, caption) => {
|
||||
lastFocus = document.activeElement;
|
||||
img.src = src;
|
||||
img.alt = caption || "";
|
||||
cap.textContent = caption || "";
|
||||
cap.hidden = !caption;
|
||||
box.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeBtn.focus();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
box.hidden = true;
|
||||
img.src = "";
|
||||
document.body.style.overflow = "";
|
||||
lastFocus?.focus();
|
||||
};
|
||||
|
||||
for (const btn of openers) {
|
||||
btn.addEventListener("click", () =>
|
||||
open(btn.dataset.src, btn.dataset.caption),
|
||||
);
|
||||
}
|
||||
closeBtn.addEventListener("click", close);
|
||||
box.addEventListener("click", (e) => {
|
||||
if (e.target === box) close();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !box.hidden) close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
160
app/src/pages/projects/index.astro
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { SITE, authors } from "../../lib/blog-data.js";
|
||||
import IndexRow from "../../components/IndexRow.astro";
|
||||
import { getProjects, getCategories } from "../../lib/projects.js";
|
||||
|
||||
const author = authors[0];
|
||||
|
||||
// Both come from cja_projects at build time. Filters are derived from what the
|
||||
// projects actually carry, so a category can never render as an empty chip —
|
||||
// tag a project "Legal" in the database and the filter appears on its own.
|
||||
const projects = await getProjects();
|
||||
const categories = await getCategories();
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Projects — ${SITE.name}`}
|
||||
description="Autonomous software, AI agents, and intelligent business systems currently in development or production."
|
||||
canonical="/projects"
|
||||
>
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-10 md:pt-28">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
{author.jobTitle}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[20ch] font-serif text-[clamp(2rem,5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
Projects
|
||||
</h1>
|
||||
<p class="mt-6 max-w-[58ch] text-base leading-relaxed text-muted-foreground">
|
||||
Autonomous software, AI agents, and intelligent business systems — some running in
|
||||
production for years, some still being built.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pb-24">
|
||||
<!-- Filters are links, not buttons: each is a real URL that can be shared,
|
||||
bookmarked, and opened in a new tab. JS upgrades them to filter in
|
||||
place; without it they still work as anchors. -->
|
||||
<nav class="ca-filters" aria-label="Filter projects">
|
||||
<a href="#all" class="ca-filter" data-filter="all" aria-current="true">
|
||||
All
|
||||
<span class="ca-filter-count">{projects.length}</span>
|
||||
</a>
|
||||
{
|
||||
categories.map((c) => (
|
||||
<a href={`#${c.slug}`} class="ca-filter" data-filter={c.slug}>
|
||||
{c.name}
|
||||
<span class="ca-filter-count">{c.count}</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 flex flex-col" data-project-list>
|
||||
{
|
||||
projects.map((project, i) => (
|
||||
<div
|
||||
data-project
|
||||
data-categories={(project.categories ?? [])
|
||||
.map((c) => c.toLowerCase())
|
||||
.join(" ")}
|
||||
style={`view-transition-name: proj-${project.slug}`}
|
||||
>
|
||||
<IndexRow
|
||||
href={`/projects/${project.slug}`}
|
||||
title={project.title}
|
||||
meta={project.period}
|
||||
summary={project.summary}
|
||||
tags={project.categories ?? project.tags}
|
||||
first={i === 0}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="ca-filter-empty" data-empty hidden>
|
||||
Nothing in that category yet.
|
||||
<a href="#all" class="ca-quiet" data-filter="all">Show everything</a>
|
||||
</p>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// Plain DOM filtering rather than a React island. Every project is already in
|
||||
// the server-rendered HTML — good for crawlers and instant on first paint —
|
||||
// so moving the list into a client component would take that away to solve a
|
||||
// problem that is a few lines of classList work.
|
||||
//
|
||||
// The animation uses the View Transitions API. Each row carries a stable
|
||||
// view-transition-name, so the browser tweens the rows that stay put as well
|
||||
// as the ones leaving and arriving. Doing this by hand would mean a FLIP
|
||||
// implementation measuring every row before and after; the platform already
|
||||
// does it, and does it better.
|
||||
const list = document.querySelector("[data-project-list]");
|
||||
const filters = [...document.querySelectorAll("[data-filter]")];
|
||||
const items = [...document.querySelectorAll("[data-project]")];
|
||||
const empty = document.querySelector("[data-empty]");
|
||||
const root = document.documentElement;
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const canTransition =
|
||||
typeof document.startViewTransition === "function" && !reduce;
|
||||
|
||||
const mutate = (active) => {
|
||||
let shown = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const cats = (item.dataset.categories || "").split(" ").filter(Boolean);
|
||||
const match = active === "all" || cats.includes(active);
|
||||
item.hidden = !match;
|
||||
if (match) shown++;
|
||||
}
|
||||
|
||||
// The top hairline follows the first VISIBLE row, not the first row —
|
||||
// otherwise filtering leaves the list with no rule or two.
|
||||
for (const item of items) {
|
||||
item.querySelector(".ca-entry")?.classList.remove("border-t", "border-border");
|
||||
}
|
||||
items
|
||||
.find((i) => !i.hidden)
|
||||
?.querySelector(".ca-entry")
|
||||
?.classList.add("border-t", "border-border");
|
||||
|
||||
for (const f of filters) {
|
||||
if (f.dataset.filter === active) f.setAttribute("aria-current", "true");
|
||||
else f.removeAttribute("aria-current");
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
};
|
||||
|
||||
const apply = (raw) => {
|
||||
const active = (raw || "all").replace(/^#/, "").toLowerCase();
|
||||
|
||||
if (!canTransition) {
|
||||
mutate(active);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scope the transition CSS to this attribute so it cannot collide with the
|
||||
// theme toggle, which drives its own ::view-transition rules.
|
||||
root.setAttribute("data-ca-filtering", "");
|
||||
const vt = document.startViewTransition(() => mutate(active));
|
||||
vt.finished.finally(() => root.removeAttribute("data-ca-filtering"));
|
||||
};
|
||||
|
||||
for (const f of filters) {
|
||||
f.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const next = f.dataset.filter || "all";
|
||||
history.replaceState(null, "", next === "all" ? location.pathname : `#${next}`);
|
||||
apply(next);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => apply(location.hash));
|
||||
mutate((location.hash || "all").replace(/^#/, "").toLowerCase());
|
||||
</script>
|
||||
228
app/src/pages/resume.astro
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import SectionHead from "../components/SectionHead.astro";
|
||||
import ResumeIntro from "../components/ui/resume-intro";
|
||||
import { SITE, authors } from "../lib/blog-data.js";
|
||||
import resume from "../config/resume.json";
|
||||
|
||||
const author = authors[0];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
mainEntity: {
|
||||
"@type": "Person",
|
||||
name: SITE.name,
|
||||
jobTitle: author.jobTitle,
|
||||
email: `mailto:${resume.contact.email}`,
|
||||
telephone: resume.contact.phone,
|
||||
description: resume.summary,
|
||||
knowsAbout: author.knowsAbout,
|
||||
alumniOf: resume.education.map((e) => ({ "@type": "Organization", name: e.org })),
|
||||
hasCredential: resume.certifications.map((c) => ({
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
name: c.title,
|
||||
})),
|
||||
},
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Resume — ${SITE.name}`}
|
||||
description={resume.summary}
|
||||
canonical="/resume"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
{/* client:visible — the block is the first thing on the page but the motion
|
||||
only matters once it is actually in view, and this keeps the JS off the
|
||||
critical path. */}
|
||||
<ResumeIntro
|
||||
client:visible
|
||||
name={SITE.name}
|
||||
headline={resume.headline}
|
||||
summary={resume.summary}
|
||||
highlights={resume.highlights}
|
||||
social={resume.social}
|
||||
email={resume.contact.email}
|
||||
resumeHref="/carlos-arias-resume.pdf"
|
||||
photoSrc="/carlos-arias-light.jpg"
|
||||
photoSrcDark="/carlos-arias-dark.jpg"
|
||||
/>
|
||||
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
<!-- ---------- experience ---------- -->
|
||||
<section class="ca-band" id="experience">
|
||||
<SectionHead title="Experience" meta="1999 — present" brush />
|
||||
|
||||
<div class="flex flex-col">
|
||||
{
|
||||
resume.experience.map((job, i) => (
|
||||
<article
|
||||
class:list={["ca-cv-row", i === 0 && "border-t border-border"]}
|
||||
>
|
||||
<div class="ca-cv-when">
|
||||
<span class="font-mono text-xs tracking-wide text-[var(--ca-ink-4)] tabular-nums">
|
||||
{job.period}
|
||||
</span>
|
||||
{job.current && (
|
||||
<span class="ca-cv-now" aria-label="Current role">
|
||||
Now
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="ca-cv-what">
|
||||
<h3 class="font-serif text-2xl leading-snug tracking-tight">{job.role}</h3>
|
||||
<p class="mt-1 text-sm">
|
||||
<span class="text-foreground">{job.org}</span>
|
||||
<span class="text-[var(--ca-ink-4)]"> · {job.sector}</span>
|
||||
</p>
|
||||
<p class="mt-4 max-w-[64ch] text-sm leading-relaxed text-muted-foreground">
|
||||
{job.summary}
|
||||
</p>
|
||||
<ul class="ca-cv-points">
|
||||
{job.points.map((point) => (
|
||||
<li>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- projects ---------- -->
|
||||
<section class="ca-band" id="ventures">
|
||||
<SectionHead title="Ventures" meta="Built and running" brush />
|
||||
|
||||
<div class="grid gap-x-14 md:grid-cols-2">
|
||||
{
|
||||
resume.projects.map((project) => (
|
||||
<article class="border-t border-border py-7">
|
||||
<div class="flex flex-wrap items-baseline gap-3">
|
||||
<h3 class="font-serif text-2xl leading-snug tracking-tight">
|
||||
{project.name}
|
||||
</h3>
|
||||
<span class="font-mono text-[0.7rem] tracking-[0.12em] text-[var(--ca-ink-4)] uppercase">
|
||||
{project.kind}
|
||||
</span>
|
||||
<span class="ml-auto font-mono text-xs whitespace-nowrap text-[var(--ca-ink-4)] tabular-nums">
|
||||
{project.period}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 max-w-[56ch] text-sm leading-relaxed text-muted-foreground">
|
||||
{project.summary}
|
||||
</p>
|
||||
<ul class="ca-cv-points">
|
||||
{project.points.map((point) => (
|
||||
<li>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- skills ---------- -->
|
||||
<section class="ca-band" id="skills">
|
||||
<SectionHead title="Skills" meta="Years in practice" />
|
||||
|
||||
<div class="grid gap-x-14 gap-y-10 md:grid-cols-2">
|
||||
{
|
||||
resume.skills.map((group) => (
|
||||
<div>
|
||||
<h3 class="mb-4 font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
|
||||
{group.group}
|
||||
</h3>
|
||||
<ul class="flex flex-col">
|
||||
{group.items.map((item) => (
|
||||
<li class="flex items-baseline gap-4 border-t border-border py-2.5 last:border-b">
|
||||
<span class="text-sm">{item.name}</span>
|
||||
{item.years && (
|
||||
<span class="ml-auto font-mono text-xs whitespace-nowrap text-[var(--ca-ink-4)] tabular-nums">
|
||||
{item.years} yr{item.years === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- tools ---------- -->
|
||||
<section class="ca-band">
|
||||
<SectionHead title="Tooling" meta="Day to day" />
|
||||
<ul class="flex max-w-5xl flex-wrap gap-2">
|
||||
{
|
||||
resume.tools.map((tool) => (
|
||||
<li class="rounded-[var(--radius)] border border-border px-2.5 py-1 font-mono text-[0.7rem] tracking-wide text-muted-foreground">
|
||||
{tool}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ---------- education ---------- -->
|
||||
<section class="ca-band" id="education">
|
||||
<div class="grid gap-x-14 gap-y-10 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 class="mb-6 font-serif text-[clamp(1.6rem,3.4vw,2.1rem)] leading-tight tracking-tight">
|
||||
Education
|
||||
</h2>
|
||||
{
|
||||
resume.education.map((item) => (
|
||||
<div class="border-t border-border py-4 last:border-b">
|
||||
<p class="font-serif text-lg leading-snug">{item.title}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{item.org}</p>
|
||||
<p class="mt-1 font-mono text-xs text-[var(--ca-ink-4)] tabular-nums">
|
||||
{item.period}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="mb-6 font-serif text-[clamp(1.6rem,3.4vw,2.1rem)] leading-tight tracking-tight">
|
||||
Certifications
|
||||
</h2>
|
||||
{
|
||||
resume.certifications.map((item) => (
|
||||
<div class="flex items-baseline gap-4 border-t border-border py-4 last:border-b">
|
||||
<p class="text-sm">{item.title}</p>
|
||||
<p class="ml-auto font-mono text-xs whitespace-nowrap text-[var(--ca-ink-4)] tabular-nums">
|
||||
{item.period}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- closing ---------- -->
|
||||
<section class="ca-band">
|
||||
<h2
|
||||
class="max-w-[20ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
The full version, <span class="ca-brushed">on paper.</span>
|
||||
</h2>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/carlos-arias-resume.pdf"
|
||||
download
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Download resume (PDF)
|
||||
</a>
|
||||
<a href="/contact" class="ca-quiet text-sm">Start a conversation</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,72 +1,132 @@
|
|||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Breadcrumbs from "../components/Breadcrumbs.astro";
|
||||
import { SITE } from "../lib/blog-data.js";
|
||||
import { SITE, authors } from "../lib/blog-data.js";
|
||||
import data from "../config/services.json";
|
||||
|
||||
const author = authors[0];
|
||||
const { services, verticals, primaryVertical } = data;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
name: SITE.name,
|
||||
jobTitle: author.jobTitle,
|
||||
url: SITE.url,
|
||||
makesOffer: services.map((s) => ({
|
||||
"@type": "Offer",
|
||||
itemOffered: { "@type": "Service", name: s.name, description: s.lead },
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Services - ${SITE.name}`}
|
||||
description={`Work with ${SITE.name} — an overview of what we offer.`}
|
||||
title={`Services — AI Automation, Websites & Marketing for Law Firms | ${SITE.name}`}
|
||||
description="AI automation, website design, and digital marketing for law firms and service businesses. Autonomous systems that bring in more qualified cases and follow up instantly."
|
||||
canonical="/services"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<div class="mx-auto max-w-3xl px-5 py-12">
|
||||
<Breadcrumbs items={[{ label: "Home", to: "/" }, { label: "Services" }]} />
|
||||
<!-- ---------- header ---------- -->
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-14 md:pt-28">
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">Services</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[18ch] font-serif text-[clamp(2.2rem,5.6vw,4.2rem)] leading-[1.05] tracking-tight text-balance"
|
||||
>
|
||||
Automation, websites, and marketing — built for
|
||||
<span class="ca-brushed">law firms.</span>
|
||||
</h1>
|
||||
<p class="mt-7 max-w-[56ch] text-base leading-relaxed text-muted-foreground">
|
||||
I help {primaryVertical.toLowerCase()} and service businesses win more qualified work and
|
||||
follow up faster — using AI automation, custom websites, and technical marketing
|
||||
that run as intelligent systems rather than manual busywork.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<header class="mt-6">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">Work with us</div>
|
||||
<h1 class="mt-2 font-serif text-5xl font-semibold leading-[1.05] tracking-tight">
|
||||
What we offer.
|
||||
</h1>
|
||||
</header>
|
||||
<!-- ---------- the three services ---------- -->
|
||||
<div class="mx-auto w-full max-w-6xl px-5">
|
||||
{
|
||||
services.map((s, i) => (
|
||||
<section class="ca-band" id={s.slug}>
|
||||
<div class="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
|
||||
<div>
|
||||
<span class="font-mono text-xs tracking-[0.14em] text-[var(--ca-ink-4)] tabular-nums">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h2 class="mt-3 font-serif text-[clamp(1.8rem,4vw,2.6rem)] leading-tight tracking-tight">
|
||||
{s.name}
|
||||
</h2>
|
||||
<p class="mt-4 max-w-[32ch] font-serif text-[clamp(1.1rem,2vw,1.35rem)] leading-snug text-[var(--ca-ink-2)]">
|
||||
{s.lead}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="prose-article mt-10">
|
||||
<p>
|
||||
This is a placeholder Services page. Describe how visitors can work with
|
||||
{SITE.name}, or delete this page if you don’t need it. Edit it in
|
||||
<code>app/src/pages/services.astro</code>.
|
||||
</p>
|
||||
<div>
|
||||
<p class="max-w-[60ch] leading-relaxed text-[var(--ca-ink-2)]">{s.body}</p>
|
||||
<ul class="ca-cv-points mt-6">
|
||||
{s.points.map((p) => (
|
||||
<li>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="mt-7 flex flex-wrap items-center gap-6">
|
||||
{s.page && (
|
||||
<a href={s.page} class="ca-quiet text-sm">
|
||||
{s.name} for law firms →
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`/contact?subject=${s.subject}`}
|
||||
class:list={["text-sm", s.page ? "ca-quiet opacity-70" : "ca-quiet"]}
|
||||
>
|
||||
Start a {s.name.toLowerCase()} project →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ---------- who I work with ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<div class="mb-10 flex flex-wrap items-baseline gap-6">
|
||||
<h2 class="font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
<span class="ca-brushed">Who I work with</span>
|
||||
</h2>
|
||||
<span class="ml-auto font-mono text-xs tracking-[0.1em] text-[var(--ca-ink-4)] uppercase">
|
||||
Primarily legal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="mt-12 border-t border-border pt-10">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">01</div>
|
||||
<h2 class="mt-2 font-serif text-3xl font-semibold tracking-tight">Service one</h2>
|
||||
<p class="mt-4 leading-relaxed text-muted-foreground">
|
||||
Describe your first offering here — what it is, who it’s for, and
|
||||
the outcome a client can expect.
|
||||
</p>
|
||||
</section>
|
||||
<div class="ca-verticals">
|
||||
{
|
||||
verticals.map((v) => (
|
||||
<div class:list={["ca-vertical", v.primary && "is-primary"]}>
|
||||
<h3>
|
||||
{v.name}
|
||||
{v.primary && <span class="ca-vertical-tag">Primary</span>}
|
||||
</h3>
|
||||
<p>{v.note}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-12 border-t border-border pt-10">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">02</div>
|
||||
<h2 class="mt-2 font-serif text-3xl font-semibold tracking-tight">Service two</h2>
|
||||
<p class="mt-4 leading-relaxed text-muted-foreground">
|
||||
Describe your second offering. Use short paragraphs and bullet lists to keep
|
||||
it scannable.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-12 border-t border-border pt-10">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.2em] text-primary">03</div>
|
||||
<h2 class="mt-2 font-serif text-3xl font-semibold tracking-tight">Service three</h2>
|
||||
<p class="mt-4 leading-relaxed text-muted-foreground">
|
||||
Describe your third offering, or remove this section if you offer fewer services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-12 border-t border-border pt-10">
|
||||
<h2 class="font-serif text-2xl font-semibold tracking-tight">Get in touch</h2>
|
||||
<p class="mt-4 leading-relaxed text-muted-foreground">
|
||||
Everything starts with a conversation. Tell us what you’re trying to
|
||||
accomplish and we’ll let you know whether we’re a good fit.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href="/contact"
|
||||
class="rounded-md bg-foreground px-6 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||
>
|
||||
Send us a message
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- ---------- closing ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2
|
||||
class="max-w-[20ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
More qualified work, <span class="ca-brushed">less manual work.</span>
|
||||
</h2>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Start a conversation
|
||||
</a>
|
||||
<a href="/projects" class="ca-quiet text-sm">See the work</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
174
app/src/pages/services/website-design.astro
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { SITE } from "../../lib/blog-data.js";
|
||||
import data from "../../config/website-design.json";
|
||||
|
||||
const { eyebrow, title, lead, intro, included, process, portfolio } = data;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
serviceType: "Law firm website design and development",
|
||||
provider: { "@type": "Person", name: SITE.name, url: SITE.url },
|
||||
areaServed: "United States",
|
||||
description: lead,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`Law Firm Website Design & Development | ${SITE.name}`}
|
||||
description="Custom law firm website design and development — fast, mobile, built for local SEO and intake. Sites that turn legal searches into signed cases."
|
||||
canonical="/services/website-design"
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<!-- ---------- header ---------- -->
|
||||
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-14 md:pt-28">
|
||||
<nav class="mb-8">
|
||||
<a href="/services" class="ca-back">
|
||||
<span aria-hidden="true">←</span> All services
|
||||
</a>
|
||||
</nav>
|
||||
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-6 max-w-[18ch] font-serif text-[clamp(2.2rem,5.6vw,4.2rem)] leading-[1.05] tracking-tight text-balance"
|
||||
>
|
||||
Law firm websites that turn searches into
|
||||
<span class="ca-brushed">signed cases.</span>
|
||||
</h1>
|
||||
<p class="mt-7 max-w-[58ch] text-base leading-relaxed text-muted-foreground">
|
||||
{lead}
|
||||
</p>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact?subject=website-design"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Start a website project
|
||||
</a>
|
||||
<a href="#work" class="ca-quiet text-sm">See the work</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- intro ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<p class="max-w-[64ch] font-serif text-[clamp(1.3rem,2.6vw,1.8rem)] leading-snug text-[var(--ca-ink-2)]">
|
||||
{intro}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ---------- portfolio ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5" id="work">
|
||||
<div class="mb-10 flex flex-wrap items-baseline gap-6">
|
||||
<h2 class="font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
<span class="ca-brushed">Recent law firm sites</span>
|
||||
</h2>
|
||||
<span class="ml-auto font-mono text-xs tracking-[0.1em] text-[var(--ca-ink-4)] uppercase">
|
||||
Selected work
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ca-folio">
|
||||
{
|
||||
portfolio.map((p) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div class="ca-folio-frame">
|
||||
<div class="ca-folio-chrome" aria-hidden="true">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
{p.image ? (
|
||||
<img src={p.image} alt={`${p.name} website`} loading="lazy" />
|
||||
) : (
|
||||
<div class="ca-folio-empty" aria-hidden="true">
|
||||
<span class="ca-folio-glyph">+</span>
|
||||
<span>Add screenshot</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="ca-folio-meta">
|
||||
<h3>{p.name}</h3>
|
||||
<p class="ca-folio-practice">{p.practice}</p>
|
||||
<p class="ca-folio-note">{p.note}</p>
|
||||
{p.url && (
|
||||
<span class="ca-folio-link">
|
||||
{p.url.replace(/^https?:\/\//, "").replace(/\/$/, "")} ↗
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return p.url ? (
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ca-folio-item"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<div class="ca-folio-item is-empty">{inner}</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- what's included ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-10 font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
<span class="ca-brushed">What’s included</span>
|
||||
</h2>
|
||||
<div class="ca-feature-grid">
|
||||
{
|
||||
included.map((f) => (
|
||||
<div class="ca-feature">
|
||||
<h3>{f.title}</h3>
|
||||
<p>{f.note}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- process ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2 class="mb-10 font-serif text-[clamp(1.8rem,4vw,2.4rem)] leading-tight tracking-tight">
|
||||
How it works
|
||||
</h2>
|
||||
<ol class="ca-process">
|
||||
{
|
||||
process.map((s) => (
|
||||
<li>
|
||||
<span class="ca-process-step">{s.step}</span>
|
||||
<div>
|
||||
<h3>{s.title}</h3>
|
||||
<p>{s.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ---------- closing ---------- -->
|
||||
<section class="ca-band mx-auto w-full max-w-6xl px-5">
|
||||
<h2
|
||||
class="max-w-[20ch] font-serif text-[clamp(1.8rem,4.5vw,3.1rem)] leading-tight tracking-tight text-balance"
|
||||
>
|
||||
Ready for a site that <span class="ca-brushed">wins cases?</span>
|
||||
</h2>
|
||||
<div class="mt-9 flex flex-wrap items-center gap-7">
|
||||
<a
|
||||
href="/contact?subject=website-design"
|
||||
class="rounded-[var(--radius)] border border-primary bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-transparent hover:text-primary"
|
||||
>
|
||||
Start a website project
|
||||
</a>
|
||||
<a href="/services" class="ca-quiet text-sm">All services</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
1855
app/src/styles.css
14
app/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"next-themes": ["src/lib/next-themes.ts"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "../public"]
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "seedproject-site",
|
||||
"url": "https://example.com",
|
||||
"name": "carlos-arias",
|
||||
"url": "https://carlosarias.co",
|
||||
"appDir": "app",
|
||||
"outDir": "public",
|
||||
"buildCommand": "npm run build",
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"model": "claude-sonnet-4-6",
|
||||
"tools": "Read Write Edit Glob Grep WebSearch",
|
||||
"confine": true,
|
||||
"agentUser": "seedproject-site-agent",
|
||||
"agentUser": "carlos-arias-agent",
|
||||
"skills": [
|
||||
"brand",
|
||||
"ui-ux",
|
||||
|
|
|
|||