Compare commits
28 Commits
449d8f96c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63481b4a4c | |||
| 1e03ca57b9 | |||
| 5c9e967163 | |||
| 4187c3bd80 | |||
| b0617d89e8 | |||
| e0c9d7c336 | |||
| a52327611b | |||
| a75239f4a4 | |||
| f42c4b8e0a | |||
| 9b8b8de875 | |||
| 2fc7de2cc8 | |||
| 7155f55d73 | |||
| 5ffb305bb0 | |||
| 1771924c69 | |||
| cb7e4d3449 | |||
| a6642bc2cc | |||
| 19191eac7c | |||
| 9589f3fa76 | |||
| a475d4c5a0 | |||
| de91f39231 | |||
| 03b988b5f5 | |||
| 59fc3bf686 | |||
| e38871879c | |||
| f4ad0bd9de | |||
| d5ec0d25ae | |||
| 20cf804ac6 | |||
| 13819410c5 | |||
| b3970b04ab |
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.output
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
## Panda
|
||||||
|
styled-system
|
||||||
|
styled-system-studio
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,7 @@ dist
|
|||||||
*.tgz
|
*.tgz
|
||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
|
.output
|
||||||
|
|
||||||
# code coverage
|
# code coverage
|
||||||
coverage
|
coverage
|
||||||
@@ -34,3 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
## Panda
|
||||||
|
styled-system
|
||||||
|
styled-system-studio
|
||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
FROM oven/bun:1.3.10-alpine AS base
|
||||||
|
|
||||||
|
# Add metadata labels
|
||||||
|
LABEL maintainer="jugger@srjuggernaut.dev"
|
||||||
|
LABEL description="Blog application built with TanStack Start"
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set proper environment
|
||||||
|
ENV BUILD=true
|
||||||
|
|
||||||
|
# Install all dependencies for build (dev + prod)
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS installer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set proper environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Install all dependencies for production
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules /app/node_modules
|
||||||
|
COPY --from=builder /app/dist /app/dist
|
||||||
|
COPY --from=builder /app/server.ts /app/server.ts
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 3000/tcp
|
||||||
|
|
||||||
|
# Add health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
|
CMD ["bun", "server.ts"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"includes": ["**", "!!node_modules", "!!bun.lock"]
|
"includes": ["**", "!!node_modules", "!!bun.lock", "!!src/routeTree.gen.ts"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
32
package.json
32
package.json
@@ -6,27 +6,35 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepare": "bun run ./.husky/install.mts"
|
"prepare": "panda codegen && bun run ./.husky/install.mts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.5",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@commitlint/cli": "^20.4.3",
|
"@commitlint/cli": "^20.5.0",
|
||||||
"@commitlint/config-conventional": "^20.4.3",
|
"@commitlint/config-conventional": "^20.5.0",
|
||||||
"@types/bun": "latest",
|
"@pandacss/dev": "^1.9.1",
|
||||||
|
"@types/bun": "1.3.11",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.3.2",
|
"lint-staged": "^16.4.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.2"
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.163.3",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@tanstack/react-start": "^1.166.1",
|
"@fontsource-variable/roboto": "^5.2.10",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
"@fontsource/orbitron": "^5.2.8",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
|
"@fortawesome/react-fontawesome": "^3.3.0",
|
||||||
|
"@srjuggernaut-dev/srjuggernaut-panda-preset": "^0.0.17",
|
||||||
|
"@tanstack/react-router": "^1.168.2",
|
||||||
|
"@tanstack/react-start": "^1.167.5",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
69
panda.config.ts
Normal file
69
panda.config.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineConfig } from '@pandacss/dev'
|
||||||
|
import srJuggernautPandaPreset from '@srjuggernaut-dev/srjuggernaut-panda-preset'
|
||||||
|
import themeConfig from '@/styles/theme'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [srJuggernautPandaPreset(themeConfig)],
|
||||||
|
// Whether to use css reset
|
||||||
|
preflight: true,
|
||||||
|
|
||||||
|
// Where to look for your css declarations
|
||||||
|
include: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||||
|
|
||||||
|
// Files to exclude
|
||||||
|
exclude: [],
|
||||||
|
|
||||||
|
// Useful for theme customization
|
||||||
|
theme: {
|
||||||
|
extend: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
globalCss: {
|
||||||
|
body: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: 'neutral.1',
|
||||||
|
color: 'neutral.12',
|
||||||
|
fontFamily: "'Roboto Variable', sans-serif",
|
||||||
|
lineHeight: 'tight'
|
||||||
|
},
|
||||||
|
'*, *::before, *::after': {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
},
|
||||||
|
'h1, h2, h3, h4, h5, h6': {
|
||||||
|
fontWeight: '700',
|
||||||
|
textWrap: 'balance',
|
||||||
|
lineHeight: 'normal'
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
fontFamily: 'orbitron',
|
||||||
|
fontWeight: '900',
|
||||||
|
fontSize: 'h1'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: 'h2'
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: 'h3'
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
fontSize: 'h4'
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontSize: 'h5'
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontSize: 'h6'
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
fontSize: 'body',
|
||||||
|
textWrap: 'pretty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// The output directory for your css system
|
||||||
|
outdir: 'styled-system'
|
||||||
|
})
|
||||||
6
postcss.config.ts
Normal file
6
postcss.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import pandacssPlugin from '@pandacss/dev/postcss'
|
||||||
|
import type { Plugin, Processor, Transformer } from 'postcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [pandacssPlugin()]
|
||||||
|
} as { plugins: (Plugin | Transformer | Processor)[] }
|
||||||
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 773 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/favicon-apple-touch.png
Normal file
BIN
public/favicon-apple-touch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
146
public/favicon.svg
Normal file
146
public/favicon.svg
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath18-5">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-900.36955,-982.1127)"
|
||||||
|
id="path18-6" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath16-2">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-1121.3406,-984.5247)"
|
||||||
|
id="path16-9" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath14-1">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-1166.4306,-571.09469)"
|
||||||
|
id="path14-2" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath12-7">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-833.56555,-568.67474)"
|
||||||
|
id="path12-0" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath10-9">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-1003.9206,-978.73275)"
|
||||||
|
id="path10-3" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath8-6">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-606.82952,-1591.4017)"
|
||||||
|
id="path8-0" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath6-6">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-1400.4006,-1591.4017)"
|
||||||
|
id="path6-2" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath4-6">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-862.51052,-672.39472)"
|
||||||
|
id="path4-1" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath2-8">
|
||||||
|
<path
|
||||||
|
d="M 0,2000 H 2000 V 0 H 0 Z"
|
||||||
|
transform="translate(-1144.7205,-672.39472)"
|
||||||
|
id="path2-7" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(0.21333333,0,0,0.21300855,276.48,196.25889)"
|
||||||
|
style="fill:#eeeeee;fill-opacity:1;paint-order:markers fill stroke;stroke:#111111;stroke-opacity:1;stroke-width:9.38214458;stroke-dasharray:none;stroke-linejoin:round;stroke-linecap:round">
|
||||||
|
<path
|
||||||
|
id="path173"
|
||||||
|
d="M 0,0 V 281.011 L 145.56,378.7 193.8,496.063 -38.59,395.585 48.25,712.774 274.98,903.33 c 0,0 71.16,-55.478 137.49,-172.464 V 436.59 Z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,67.4332,899.72155)"
|
||||||
|
clip-path="url(#clipPath2-8)" />
|
||||||
|
<path
|
||||||
|
id="path174"
|
||||||
|
d="M 0,0 V 281.011 L -145.554,378.7 -193.796,496.063 38.593,395.585 -48.242,712.774 -274.978,903.33 c 0,0 -71.156,-55.48 -137.488,-172.466 V 436.59 Z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,-251.2672,899.72155)"
|
||||||
|
clip-path="url(#clipPath4-6)" />
|
||||||
|
<path
|
||||||
|
id="path175"
|
||||||
|
d="m 0,0 -194.55,-164.022 66.71,253.269 c 0,0 91.66,-50.654 127.84,-89.247"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,356.1732,-212.59967)"
|
||||||
|
clip-path="url(#clipPath6-6)" />
|
||||||
|
<path
|
||||||
|
id="path176"
|
||||||
|
d="M 0,0 194.55,-164.022 127.841,89.247 C 127.841,89.247 36.181,38.593 0,0"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,-540.00824,-212.59967)"
|
||||||
|
clip-path="url(#clipPath8-6)" />
|
||||||
|
<path
|
||||||
|
id="path177"
|
||||||
|
d="m 0,0 68.44,50.654 183.32,677.795 c 0,0 -231.56,229.147 -501.714,0 33.769,-118.192 183.318,-677.8 183.318,-677.8 z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,-91.5725,528.94505)"
|
||||||
|
clip-path="url(#clipPath10-9)" />
|
||||||
|
<path
|
||||||
|
id="path178"
|
||||||
|
d="m 0,0 -26.533,74.78 -410.054,446.234 -2.412,168.845 -156.785,-118.192 -125.429,-69.95 -108.543,-106.132 173.669,28.945 176.083,-94.071 -14.473,-50.654 L -62.714,-9.64 Z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,-283.95482,1025.2591)"
|
||||||
|
clip-path="url(#clipPath12-7)" />
|
||||||
|
<path
|
||||||
|
id="path179"
|
||||||
|
d="M 0,0 26.54,74.77 436.59,521.006 439,689.851 595.79,571.659 721.21,501.709 829.76,395.578 656.09,424.522 480.01,330.451 494.48,279.798 62.72,-9.65 Z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,91.9503,1022.3302)"
|
||||||
|
clip-path="url(#clipPath14-1)" />
|
||||||
|
<path
|
||||||
|
id="path180"
|
||||||
|
d="M 0,0 28.58,66.523 170.31,141.547 148.07,83.058 Z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,41.03,521.93465)"
|
||||||
|
clip-path="url(#clipPath16-2)" />
|
||||||
|
<path
|
||||||
|
id="path181"
|
||||||
|
d="m 0,0 -28.579,66.523 -141.731,75.024 22.241,-58.489 z"
|
||||||
|
style="display:inline;fill-opacity:1;fill-rule:evenodd;paint-order:markers fill stroke"
|
||||||
|
transform="matrix(1.1293021,0,0,-1.2103512,-208.5129,524.85405)"
|
||||||
|
clip-path="url(#clipPath18-5)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
566
server.ts
Normal file
566
server.ts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
/**
|
||||||
|
* TanStack Start Production Server with Bun
|
||||||
|
*
|
||||||
|
* A high-performance production server for TanStack Start applications that
|
||||||
|
* implements intelligent static asset loading with configurable memory management.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Hybrid loading strategy (preload small files, serve large files on-demand)
|
||||||
|
* - Configurable file filtering with include/exclude patterns
|
||||||
|
* - Memory-efficient response generation
|
||||||
|
* - Production-ready caching headers
|
||||||
|
*
|
||||||
|
* Environment Variables:
|
||||||
|
*
|
||||||
|
* PORT (number)
|
||||||
|
* - Server port number
|
||||||
|
* - Default: 3000
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_MAX_SIZE (number)
|
||||||
|
* - Maximum file size in bytes to preload into memory
|
||||||
|
* - Files larger than this will be served on-demand from disk
|
||||||
|
* - Default: 5242880 (5MB)
|
||||||
|
* - Example: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
|
||||||
|
* - Comma-separated list of glob patterns for files to include
|
||||||
|
* - If specified, only matching files are eligible for preloading
|
||||||
|
* - Patterns are matched against filenames only, not full paths
|
||||||
|
* - Example: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
|
||||||
|
* - Comma-separated list of glob patterns for files to exclude
|
||||||
|
* - Applied after include patterns
|
||||||
|
* - Patterns are matched against filenames only, not full paths
|
||||||
|
* - Example: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
|
||||||
|
* - Enable detailed logging of loaded and skipped files
|
||||||
|
* - Default: false
|
||||||
|
* - Set to "true" to enable verbose output
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
|
||||||
|
* - Enable ETag generation for preloaded assets
|
||||||
|
* - Default: true
|
||||||
|
* - Set to "false" to disable ETag support
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
|
||||||
|
* - Enable Gzip compression for eligible assets
|
||||||
|
* - Default: true
|
||||||
|
* - Set to "false" to disable Gzip compression
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
|
||||||
|
* - Minimum file size in bytes required for Gzip compression
|
||||||
|
* - Files smaller than this will not be compressed
|
||||||
|
* - Default: 1024 (1KB)
|
||||||
|
*
|
||||||
|
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
|
||||||
|
* - Comma-separated list of MIME types eligible for Gzip compression
|
||||||
|
* - Supports partial matching for types ending with "/"
|
||||||
|
* - Default: text/,application/javascript,application/json,application/xml,image/svg+xml
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run server.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const SERVER_PORT = Number(process.env.PORT ?? 3000)
|
||||||
|
const CLIENT_DIRECTORY = './dist/client'
|
||||||
|
const SERVER_ENTRY_POINT = './dist/server/server.js'
|
||||||
|
|
||||||
|
// Logging utilities for professional output
|
||||||
|
const log = {
|
||||||
|
info: (message: string) => {
|
||||||
|
console.log(`[INFO] ${message}`)
|
||||||
|
},
|
||||||
|
success: (message: string) => {
|
||||||
|
console.log(`[SUCCESS] ${message}`)
|
||||||
|
},
|
||||||
|
warning: (message: string) => {
|
||||||
|
console.log(`[WARNING] ${message}`)
|
||||||
|
},
|
||||||
|
error: (message: string) => {
|
||||||
|
console.log(`[ERROR] ${message}`)
|
||||||
|
},
|
||||||
|
header: (message: string) => {
|
||||||
|
console.log(`\n${message}\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preloading configuration from environment variables
|
||||||
|
const MAX_PRELOAD_BYTES = Number(
|
||||||
|
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024 // 5MB default
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse comma-separated include patterns (no defaults)
|
||||||
|
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pattern: string) => convertGlobToRegExp(pattern))
|
||||||
|
|
||||||
|
// Parse comma-separated exclude patterns (no defaults)
|
||||||
|
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pattern: string) => convertGlobToRegExp(pattern))
|
||||||
|
|
||||||
|
// Verbose logging flag
|
||||||
|
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'
|
||||||
|
|
||||||
|
// Optional ETag feature
|
||||||
|
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'
|
||||||
|
|
||||||
|
// Optional Gzip feature
|
||||||
|
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
|
||||||
|
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 1KB
|
||||||
|
const GZIP_TYPES = (
|
||||||
|
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
|
||||||
|
'text/,application/javascript,application/json,application/xml,image/svg+xml'
|
||||||
|
)
|
||||||
|
.split(',')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a simple glob pattern to a regular expression
|
||||||
|
* Supports * wildcard for matching any characters
|
||||||
|
*/
|
||||||
|
function convertGlobToRegExp(globPattern: string): RegExp {
|
||||||
|
// Escape regex special chars except *, then replace * with .*
|
||||||
|
const escapedPattern = globPattern
|
||||||
|
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
return new RegExp(`^${escapedPattern}$`, 'i')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute ETag for a given data buffer
|
||||||
|
*/
|
||||||
|
function computeEtag(data: Uint8Array): string {
|
||||||
|
const hash = Bun.hash(data)
|
||||||
|
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for preloaded static assets
|
||||||
|
*/
|
||||||
|
interface AssetMetadata {
|
||||||
|
route: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory asset with ETag and Gzip support
|
||||||
|
*/
|
||||||
|
interface InMemoryAsset {
|
||||||
|
raw: Uint8Array
|
||||||
|
gz?: Uint8Array
|
||||||
|
etag?: string
|
||||||
|
type: string
|
||||||
|
immutable: boolean
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of static asset preloading process
|
||||||
|
*/
|
||||||
|
interface PreloadResult {
|
||||||
|
routes: Record<string, (req: Request) => Response | Promise<Response>>
|
||||||
|
loaded: AssetMetadata[]
|
||||||
|
skipped: AssetMetadata[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is eligible for preloading based on configured patterns
|
||||||
|
*/
|
||||||
|
function isFileEligibleForPreloading(relativePath: string): boolean {
|
||||||
|
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
|
||||||
|
|
||||||
|
// If include patterns are specified, file must match at least one
|
||||||
|
if (INCLUDE_PATTERNS.length > 0) {
|
||||||
|
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exclude patterns are specified, file must not match any
|
||||||
|
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a MIME type is compressible
|
||||||
|
*/
|
||||||
|
function isMimeTypeCompressible(mimeType: string): boolean {
|
||||||
|
return GZIP_TYPES.some((type) =>
|
||||||
|
type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditionally compress data based on size and MIME type
|
||||||
|
*/
|
||||||
|
function compressDataIfAppropriate(
|
||||||
|
data: Uint8Array,
|
||||||
|
mimeType: string
|
||||||
|
): Uint8Array | undefined {
|
||||||
|
if (!ENABLE_GZIP) return undefined
|
||||||
|
if (data.byteLength < GZIP_MIN_BYTES) return undefined
|
||||||
|
if (!isMimeTypeCompressible(mimeType)) return undefined
|
||||||
|
try {
|
||||||
|
return Bun.gzipSync(data.buffer as ArrayBuffer)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create response handler function with ETag and Gzip support
|
||||||
|
*/
|
||||||
|
function createResponseHandler(
|
||||||
|
asset: InMemoryAsset
|
||||||
|
): (req: Request) => Response {
|
||||||
|
return (req: Request) => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': asset.type,
|
||||||
|
'Cache-Control': asset.immutable
|
||||||
|
? 'public, max-age=31536000, immutable'
|
||||||
|
: 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENABLE_ETAG && asset.etag) {
|
||||||
|
const ifNone = req.headers.get('if-none-match')
|
||||||
|
if (ifNone && ifNone === asset.etag) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: { ETag: asset.etag }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
headers.ETag = asset.etag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ENABLE_GZIP &&
|
||||||
|
asset.gz &&
|
||||||
|
req.headers.get('accept-encoding')?.includes('gzip')
|
||||||
|
) {
|
||||||
|
headers['Content-Encoding'] = 'gzip'
|
||||||
|
headers['Content-Length'] = String(asset.gz.byteLength)
|
||||||
|
const gzCopy = new Uint8Array(asset.gz)
|
||||||
|
return new Response(gzCopy, { status: 200, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
headers['Content-Length'] = String(asset.raw.byteLength)
|
||||||
|
const rawCopy = new Uint8Array(asset.raw)
|
||||||
|
return new Response(rawCopy, { status: 200, headers })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create composite glob pattern from include patterns
|
||||||
|
*/
|
||||||
|
function createCompositeGlobPattern(): Bun.Glob {
|
||||||
|
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (raw.length === 0) return new Bun.Glob('**/*')
|
||||||
|
if (raw.length === 1) return new Bun.Glob(raw[0] as string)
|
||||||
|
return new Bun.Glob(`{${raw.join(',')}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize static routes with intelligent preloading strategy
|
||||||
|
* Small files are loaded into memory, large files are served on-demand
|
||||||
|
*/
|
||||||
|
async function initializeStaticRoutes(
|
||||||
|
clientDirectory: string
|
||||||
|
): Promise<PreloadResult> {
|
||||||
|
const routes: Record<string, (req: Request) => Response | Promise<Response>> =
|
||||||
|
{}
|
||||||
|
const loaded: AssetMetadata[] = []
|
||||||
|
const skipped: AssetMetadata[] = []
|
||||||
|
|
||||||
|
log.info(`Loading static assets from ${clientDirectory}...`)
|
||||||
|
if (VERBOSE) {
|
||||||
|
console.log(
|
||||||
|
`Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
)
|
||||||
|
if (INCLUDE_PATTERNS.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`Include patterns: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (EXCLUDE_PATTERNS.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`Exclude patterns: ${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalPreloadedBytes = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const glob = createCompositeGlobPattern()
|
||||||
|
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
|
||||||
|
const filepath = path.join(clientDirectory, relativePath)
|
||||||
|
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get file metadata
|
||||||
|
const file = Bun.file(filepath)
|
||||||
|
|
||||||
|
// Skip if file doesn't exist or is empty
|
||||||
|
if (!(await file.exists()) || file.size === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: AssetMetadata = {
|
||||||
|
route,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if file should be preloaded
|
||||||
|
const matchesPattern = isFileEligibleForPreloading(relativePath)
|
||||||
|
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
|
||||||
|
|
||||||
|
if (matchesPattern && withinSizeLimit) {
|
||||||
|
// Preload small files into memory with ETag and Gzip support
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||||
|
const gz = compressDataIfAppropriate(bytes, metadata.type)
|
||||||
|
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
|
||||||
|
const asset: InMemoryAsset = {
|
||||||
|
raw: bytes,
|
||||||
|
gz,
|
||||||
|
etag,
|
||||||
|
type: metadata.type,
|
||||||
|
immutable: true,
|
||||||
|
size: bytes.byteLength
|
||||||
|
}
|
||||||
|
routes[route] = createResponseHandler(asset)
|
||||||
|
|
||||||
|
loaded.push({ ...metadata, size: bytes.byteLength })
|
||||||
|
totalPreloadedBytes += bytes.byteLength
|
||||||
|
} else {
|
||||||
|
// Serve large or filtered files on-demand
|
||||||
|
routes[route] = () => {
|
||||||
|
const fileOnDemand = Bun.file(filepath)
|
||||||
|
return new Response(fileOnDemand, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': metadata.type,
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped.push(metadata)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name !== 'EISDIR') {
|
||||||
|
log.error(`Failed to load ${filepath}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed file overview only when verbose mode is enabled
|
||||||
|
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
|
||||||
|
const allFiles = [...loaded, ...skipped].sort((a, b) =>
|
||||||
|
a.route.localeCompare(b.route)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate max path length for alignment
|
||||||
|
const maxPathLength = Math.min(
|
||||||
|
Math.max(...allFiles.map((f) => f.route.length)),
|
||||||
|
60
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format file size with KB and actual gzip size
|
||||||
|
const formatFileSize = (bytes: number, gzBytes?: number) => {
|
||||||
|
const kb = bytes / 1024
|
||||||
|
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)
|
||||||
|
|
||||||
|
if (gzBytes !== undefined) {
|
||||||
|
const gzKb = gzBytes / 1024
|
||||||
|
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
|
||||||
|
return {
|
||||||
|
size: sizeStr,
|
||||||
|
gzip: gzStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough gzip estimation (typically 30-70% compression) if no actual gzip data
|
||||||
|
const gzipKb = kb * 0.35
|
||||||
|
return {
|
||||||
|
size: sizeStr,
|
||||||
|
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
console.log('\n📁 Preloaded into memory:')
|
||||||
|
console.log(
|
||||||
|
'Path │ Size │ Gzip Size'
|
||||||
|
)
|
||||||
|
loaded
|
||||||
|
.sort((a, b) => a.route.localeCompare(b.route))
|
||||||
|
.forEach((file) => {
|
||||||
|
const { size, gzip } = formatFileSize(file.size)
|
||||||
|
const paddedPath = file.route.padEnd(maxPathLength)
|
||||||
|
const sizeStr = `${size.padStart(7)} kB`
|
||||||
|
const gzipStr = `${gzip.padStart(7)} kB`
|
||||||
|
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log('\n💾 Served on-demand:')
|
||||||
|
console.log(
|
||||||
|
'Path │ Size │ Gzip Size'
|
||||||
|
)
|
||||||
|
skipped
|
||||||
|
.sort((a, b) => a.route.localeCompare(b.route))
|
||||||
|
.forEach((file) => {
|
||||||
|
const { size, gzip } = formatFileSize(file.size)
|
||||||
|
const paddedPath = file.route.padEnd(maxPathLength)
|
||||||
|
const sizeStr = `${size.padStart(7)} kB`
|
||||||
|
const gzipStr = `${gzip.padStart(7)} kB`
|
||||||
|
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed verbose info if enabled
|
||||||
|
if (VERBOSE) {
|
||||||
|
if (loaded.length > 0 || skipped.length > 0) {
|
||||||
|
const allFiles = [...loaded, ...skipped].sort((a, b) =>
|
||||||
|
a.route.localeCompare(b.route)
|
||||||
|
)
|
||||||
|
console.log('\n📊 Detailed file information:')
|
||||||
|
console.log(
|
||||||
|
'Status │ Path │ MIME Type │ Reason'
|
||||||
|
)
|
||||||
|
allFiles.forEach((file) => {
|
||||||
|
const isPreloaded = loaded.includes(file)
|
||||||
|
const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
|
||||||
|
const reason =
|
||||||
|
!isPreloaded && file.size > MAX_PRELOAD_BYTES
|
||||||
|
? 'too large'
|
||||||
|
: !isPreloaded
|
||||||
|
? 'filtered'
|
||||||
|
: 'preloaded'
|
||||||
|
const route =
|
||||||
|
file.route.length > 30
|
||||||
|
? file.route.substring(0, 27) + '...'
|
||||||
|
: file.route
|
||||||
|
console.log(
|
||||||
|
`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('\n📊 No files found to display')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log summary after the file list
|
||||||
|
console.log() // Empty line for separation
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
log.success(
|
||||||
|
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.info('No files preloaded into memory')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
|
||||||
|
const filtered = skipped.length - tooLarge
|
||||||
|
log.info(
|
||||||
|
`${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
`Failed to load static files from ${clientDirectory}: ${String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routes, loaded, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the server
|
||||||
|
*/
|
||||||
|
async function initializeServer() {
|
||||||
|
log.header('Starting Production Server')
|
||||||
|
|
||||||
|
// Load TanStack Start server handler
|
||||||
|
let handler: { fetch: (request: Request) => Response | Promise<Response> }
|
||||||
|
try {
|
||||||
|
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
|
||||||
|
default: { fetch: (request: Request) => Response | Promise<Response> }
|
||||||
|
}
|
||||||
|
handler = serverModule.default
|
||||||
|
log.success('TanStack Start application handler initialized')
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to load server handler: ${String(error)}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build static routes with intelligent preloading
|
||||||
|
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
|
||||||
|
|
||||||
|
// Create Bun server
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: SERVER_PORT,
|
||||||
|
|
||||||
|
routes: {
|
||||||
|
// Serve static assets (preloaded or on-demand)
|
||||||
|
...routes,
|
||||||
|
|
||||||
|
// Fallback to TanStack Start handler for all other routes
|
||||||
|
'/*': (req: Request) => {
|
||||||
|
try {
|
||||||
|
return handler.fetch(req)
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Server handler error: ${String(error)}`)
|
||||||
|
return new Response('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
error(error) {
|
||||||
|
log.error(
|
||||||
|
`Uncaught server error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return new Response('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
log.info('Shutting down server...')
|
||||||
|
server.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
log.info('Shutting down server...')
|
||||||
|
server.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
log.success(`Server listening on http://localhost:${String(server.port)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the server
|
||||||
|
initializeServer().catch((error: unknown) => {
|
||||||
|
log.error(`Failed to start server: ${String(error)}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
35
src/components/NotFound.tsx
Normal file
35
src/components/NotFound.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { css } from '@styled-system/css'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import FullWidth from '@/components/layout/FullWidth'
|
||||||
|
|
||||||
|
const NotFoundStyle = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center'
|
||||||
|
})
|
||||||
|
|
||||||
|
const NotFound: FC = () => {
|
||||||
|
return (
|
||||||
|
<FullWidth className={NotFoundStyle}>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: '100px',
|
||||||
|
fontFamily: 'orbitron',
|
||||||
|
fontWeight: '900',
|
||||||
|
lineHeight: 'none',
|
||||||
|
color: 'primary.9'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</span>
|
||||||
|
<h1 className={css({ fontSize: '45px' })}>Pagina no encontrada.</h1>
|
||||||
|
<p>
|
||||||
|
La pagina que estas buscando no existe o ha sido movida. Puedes volver a
|
||||||
|
la <a href="/">pagina principal</a>, o intentar de nuevo mas tarde.
|
||||||
|
</p>
|
||||||
|
</FullWidth>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFound
|
||||||
60
src/components/assets/SrJuggernautLogo.tsx
Normal file
60
src/components/assets/SrJuggernautLogo.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { css, cx } from '@styled-system/css'
|
||||||
|
import type { FC, SVGProps } from 'react'
|
||||||
|
|
||||||
|
export type SrJuggernautLogoProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
const SrJuggernautLogo: FC<SrJuggernautLogoProps> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 2250 1513"
|
||||||
|
className={cx(
|
||||||
|
css({ display: 'inline-block', fill: 'currentColor', height: 'auto' }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Sr Juggernaut Logo</title>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M0 0v281.011L145.56 378.7l48.24 117.363-232.39-100.478 86.84 317.189L274.98 903.33s71.16-55.478 137.49-172.464V436.59Z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1288.433 1375.722)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0v281.011L-145.554 378.7l-48.242 117.363L38.593 395.585l-86.835 317.189-226.736 190.556s-71.156-55.48-137.488-172.466V436.59Z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 969.733 1375.722)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0-194.55-164.022 66.71 253.269S-36.18 38.593 0 0"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1577.173 263.4)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0 194.55-164.022-66.709 253.269S36.181 38.593 0 0"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 680.992 263.4)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0 68.44 50.654 183.32 677.795s-231.56 229.147-501.714 0c33.769-118.192 183.318-677.8 183.318-677.8z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1129.428 1004.945)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0-26.533 74.78-410.054 446.234-2.412 168.845-156.785-118.192-125.429-69.95-108.543-106.132 173.669 28.945 176.083-94.071-14.473-50.654L-62.714-9.64Z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 937.045 1501.26)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0 26.54 74.77 410.05 446.236L439 689.851l156.79-118.192 125.42-69.95 108.55-106.131-173.67 28.944-176.08-94.071 14.47-50.653L62.72-9.65Z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1312.95 1498.33)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0 28.58 66.523 141.73 75.024-22.24-58.489Z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1262.03 997.935)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 0-28.579 66.523-141.731 75.024 22.241-58.489z"
|
||||||
|
transform="matrix(1.1293 0 0 -1.21035 1012.487 1000.854)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
export default SrJuggernautLogo
|
||||||
13
src/components/layout/FullWidth.tsx
Normal file
13
src/components/layout/FullWidth.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { css, cx } from '@styled-system/css'
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface FullWidthProps {
|
||||||
|
className?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullWidth: FC<FullWidthProps> = ({ className, children }) => {
|
||||||
|
return <main className={cx(css({ flexGrow: 1 }), className)}>{children}</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FullWidth
|
||||||
124
src/components/layout/fragments/Footer.tsx
Normal file
124
src/components/layout/fragments/Footer.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { faChevronRight, faHeart } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { css } from '@styled-system/css'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
const footerClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const footerContainerClass = css({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: {
|
||||||
|
sm: 'breakpoint-sm',
|
||||||
|
md: 'breakpoint-md',
|
||||||
|
lg: 'breakpoint-lg',
|
||||||
|
xl: 'breakpoint-xl',
|
||||||
|
'2xl': 'breakpoint-2xl'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const footerLegendClass = css({
|
||||||
|
fontSize: 'sm'
|
||||||
|
})
|
||||||
|
|
||||||
|
const footerLegendIconClass = css({
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
|
||||||
|
const footerLinkGroupClass = css({
|
||||||
|
padding: 'sm'
|
||||||
|
})
|
||||||
|
|
||||||
|
const footerLinkGroupTitleClass = css({
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 'h3',
|
||||||
|
textAlign: 'center'
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkGroup {
|
||||||
|
heading: string
|
||||||
|
links: Link[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkGroups: LinkGroup[] = [
|
||||||
|
{
|
||||||
|
heading: 'Sobre Mi',
|
||||||
|
links: [{ label: 'Como Desarrollador', href: 'https://srjuggernaut.dev/' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Redes Sociales',
|
||||||
|
links: [
|
||||||
|
{ label: 'Twitch', href: 'https://www.twitch.tv/juggernautplays' },
|
||||||
|
{ label: 'Youtube', href: 'https://www.youtube.com/JuggernautPlays' },
|
||||||
|
{
|
||||||
|
label: 'BlueSky',
|
||||||
|
href: 'https://bsky.app/profile/jugger.srjuggernaut.dev'
|
||||||
|
},
|
||||||
|
{ label: 'Twitter', href: 'https://twitter.com/juggernautplays' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const Footer: FC = () => {
|
||||||
|
return (
|
||||||
|
<footer className={footerClass}>
|
||||||
|
<div className={footerContainerClass}>
|
||||||
|
{linkGroups.map((group) => (
|
||||||
|
<div
|
||||||
|
className={footerLinkGroupClass}
|
||||||
|
key={group.heading}
|
||||||
|
>
|
||||||
|
<span className={footerLinkGroupTitleClass}>{group.heading}</span>
|
||||||
|
<ul className="fa-ul">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<span className="fa-li">
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</span>
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={footerLegendClass}>
|
||||||
|
Made by{' '}
|
||||||
|
<a
|
||||||
|
href="https://srjuggernaut.dev/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
SrJuggernaut
|
||||||
|
</a>{' '}
|
||||||
|
with{' '}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faHeart}
|
||||||
|
className={footerLegendIconClass}
|
||||||
|
/>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href="https://tanstack.com/start/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
TanStack Start
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
96
src/components/layout/fragments/Header.tsx
Normal file
96
src/components/layout/fragments/Header.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { css, cx } from '@styled-system/css'
|
||||||
|
import { token } from '@styled-system/tokens'
|
||||||
|
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||||
|
import SrJuggernautLogo from '@/components/assets/SrJuggernautLogo'
|
||||||
|
import MainMenu from '@/components/layout/fragments/MainMenu'
|
||||||
|
import srOnlyClass from '@/styles/srOnly'
|
||||||
|
|
||||||
|
const headerClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
transition: `background-color ${token('durations.slow')} ${token('easings.easeOutQuint')}`,
|
||||||
|
zIndex: 1,
|
||||||
|
flexShrink: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerUnscrolledClass = css({
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerScrolledClass = css({
|
||||||
|
backgroundColor: 'neutral.2'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerContainerClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
padding: 'md',
|
||||||
|
maxWidth: {
|
||||||
|
sm: 'breakpoint-sm',
|
||||||
|
md: 'breakpoint-md',
|
||||||
|
lg: 'breakpoint-lg',
|
||||||
|
xl: 'breakpoint-xl',
|
||||||
|
'2xl': 'breakpoint-2xl'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerLogoLinkClass = css({
|
||||||
|
color: 'neutral.12',
|
||||||
|
cursor: 'pointer'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerLogoClass = css({
|
||||||
|
fill: 'neutral.12',
|
||||||
|
height: '30px',
|
||||||
|
width: 'auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header: FC = () => {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (window.scrollY > 0) {
|
||||||
|
setScrolled(true)
|
||||||
|
} else {
|
||||||
|
setScrolled(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cx(
|
||||||
|
headerClass,
|
||||||
|
scrolled ? headerScrolledClass : headerUnscrolledClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={headerContainerClass}>
|
||||||
|
<a
|
||||||
|
className={headerLogoLinkClass}
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<SrJuggernautLogo className={headerLogoClass} />
|
||||||
|
<span className={srOnlyClass}>Ir a la página principal</span>
|
||||||
|
</a>
|
||||||
|
<MainMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
186
src/components/layout/fragments/MainMenu.tsx
Normal file
186
src/components/layout/fragments/MainMenu.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { faBars, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { css } from '@styled-system/css'
|
||||||
|
import { token } from '@styled-system/tokens'
|
||||||
|
import { type FC, type ReactNode, useRef } from 'react'
|
||||||
|
import SrJuggernautLogo from '@/components/assets/SrJuggernautLogo'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Menu, {
|
||||||
|
MenuGroup,
|
||||||
|
MenuItem,
|
||||||
|
MenuLabel,
|
||||||
|
MenuSeparator
|
||||||
|
} from '@/components/ui/Menu'
|
||||||
|
import srOnlyClass from '@/styles/srOnly'
|
||||||
|
|
||||||
|
const menuDialogClass = css({
|
||||||
|
position: 'fixed',
|
||||||
|
height: '100dvh',
|
||||||
|
width: {
|
||||||
|
base: '100%',
|
||||||
|
sm: '250px'
|
||||||
|
},
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 'auto',
|
||||||
|
backgroundColor: 'neutral.2',
|
||||||
|
color: 'neutral.12',
|
||||||
|
transition: `transform ${token('durations.normal')} ${token('easings.easeOutQuint')}`,
|
||||||
|
transitionBehavior: 'allow-discrete',
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
'@starting-style': {
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
_backdrop: {
|
||||||
|
opacity: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
transition: `background-color ${token('durations.normal')} ${token('easings.easeOutQuint')}, opacity ${token('durations.fast')} ${token('easings.easeOutQuint')}`,
|
||||||
|
transitionBehavior: 'allow-discrete'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_backdrop: {
|
||||||
|
opacity: 1,
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
backgroundColor: 'neutral.1/80',
|
||||||
|
transition: `background-color ${token('durations.normal')} ${token('easings.easeOutQuint')}, opacity ${token('durations.fast')} ${token('easings.easeOutQuint')}`,
|
||||||
|
transitionBehavior: 'allow-discrete'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuHeaderContainerClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
padding: 'md'
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuHeaderLogoLinkClass = css({
|
||||||
|
color: 'neutral.12',
|
||||||
|
cursor: 'pointer'
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuHeaderLogoClass = css({
|
||||||
|
fill: 'neutral.12',
|
||||||
|
height: '30px',
|
||||||
|
width: 'auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
interface MainMenuLink {
|
||||||
|
type: 'link'
|
||||||
|
href: string
|
||||||
|
label: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainMenuLabel {
|
||||||
|
type: 'label'
|
||||||
|
label: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainMenuSeparator {
|
||||||
|
type: 'separator'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainMenuGroup {
|
||||||
|
type: 'group'
|
||||||
|
label: ReactNode
|
||||||
|
content: (MainMenuLink | MainMenuLabel | MainMenuSeparator)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuItemType =
|
||||||
|
| MainMenuLink
|
||||||
|
| MainMenuSeparator
|
||||||
|
| MainMenuLabel
|
||||||
|
| MainMenuGroup
|
||||||
|
|
||||||
|
const menuContent: MenuItemType[] = [
|
||||||
|
{ type: 'link', href: '/', label: 'Inicio' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const RenderMenuItem: FC<MenuItemType> = (item) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'link':
|
||||||
|
return (
|
||||||
|
<MenuItem render={<a href={item.href}>{item.label}</a>}>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
case 'label':
|
||||||
|
return <MenuLabel>{item.label}</MenuLabel>
|
||||||
|
case 'separator':
|
||||||
|
return <MenuSeparator />
|
||||||
|
case 'group':
|
||||||
|
return (
|
||||||
|
<MenuGroup label={item.label}>
|
||||||
|
{item.content.map((item, index) => (
|
||||||
|
<RenderMenuItem
|
||||||
|
key={`group-${item.type}-${index.toString()}`}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MenuGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainMenu: FC = () => {
|
||||||
|
const DialogMenuRef = useRef<HTMLDialogElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (DialogMenuRef.current) {
|
||||||
|
DialogMenuRef.current.showModal()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBars} />
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={DialogMenuRef}
|
||||||
|
className={menuDialogClass}
|
||||||
|
closedby="any"
|
||||||
|
>
|
||||||
|
<header className={menuHeaderContainerClass}>
|
||||||
|
<a
|
||||||
|
className={menuHeaderLogoLinkClass}
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<SrJuggernautLogo className={menuHeaderLogoClass} />
|
||||||
|
<span className={srOnlyClass}>Ir a la página principal</span>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (DialogMenuRef.current) {
|
||||||
|
DialogMenuRef.current.close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTimes} />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<Menu
|
||||||
|
render={<menu />}
|
||||||
|
className={css({
|
||||||
|
width: '100%'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{menuContent.map((item, index) => (
|
||||||
|
<RenderMenuItem
|
||||||
|
key={`menu-${item.type}-${index.toString()}`}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainMenu
|
||||||
22
src/components/ui/Button.tsx
Normal file
22
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cx } from '@styled-system/css'
|
||||||
|
import { type ButtonVariantProps, button } from '@styled-system/recipes/button'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { MergeOmitting } from '@/types/helpers'
|
||||||
|
|
||||||
|
export type ButtonProps = MergeOmitting<
|
||||||
|
useRender.ComponentProps<'button'>,
|
||||||
|
ButtonVariantProps
|
||||||
|
>
|
||||||
|
|
||||||
|
const Button: FC<ButtonProps> = ({ render, className, ...props }) => {
|
||||||
|
const [buttonArgs, otherProps] = button.splitVariantProps(props)
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'button',
|
||||||
|
render,
|
||||||
|
props: { className: cx(button(buttonArgs), className), ...otherProps }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button
|
||||||
107
src/components/ui/Menu.tsx
Normal file
107
src/components/ui/Menu.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cx } from '@styled-system/css'
|
||||||
|
import { type MenuVariantProps, menu } from '@styled-system/recipes/menu'
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import type { MergeOmitting } from '@/types/helpers'
|
||||||
|
|
||||||
|
export type MenuProps = MergeOmitting<
|
||||||
|
useRender.ComponentProps<'div'>,
|
||||||
|
MenuVariantProps
|
||||||
|
>
|
||||||
|
|
||||||
|
const Menu: FC<MenuProps> = ({ render, className, ...props }) => {
|
||||||
|
const [menuProps, allOther] = menu.splitVariantProps(props)
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
render,
|
||||||
|
props: { className: cx(menu(menuProps).container, className), ...allOther }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Menu
|
||||||
|
|
||||||
|
export type MenuItemProps = MergeOmitting<
|
||||||
|
useRender.ComponentProps<'button'>,
|
||||||
|
MenuVariantProps
|
||||||
|
>
|
||||||
|
|
||||||
|
export const MenuItem: FC<MenuItemProps> = ({
|
||||||
|
render,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [menuProps, allOther] = menu.splitVariantProps(props)
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'button',
|
||||||
|
render,
|
||||||
|
props: { className: cx(menu(menuProps).item, className), ...allOther }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuLabelProps = MergeOmitting<
|
||||||
|
useRender.ComponentProps<'span'>,
|
||||||
|
MenuVariantProps
|
||||||
|
>
|
||||||
|
|
||||||
|
export const MenuLabel: FC<MenuLabelProps> = ({
|
||||||
|
render,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [menuProps, allOther] = menu.splitVariantProps(props)
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'span',
|
||||||
|
render,
|
||||||
|
props: { className: cx(menu(menuProps).label, className), ...allOther }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuGroupProps = MergeOmitting<
|
||||||
|
useRender.ComponentProps<'div'>,
|
||||||
|
MenuVariantProps & { label: ReactNode }
|
||||||
|
>
|
||||||
|
|
||||||
|
export const MenuGroup: FC<MenuGroupProps> = ({
|
||||||
|
render,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [menuProps, allOther] = menu.splitVariantProps(props)
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
render,
|
||||||
|
|
||||||
|
props: {
|
||||||
|
className: cx(menu(menuProps).group, className),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<span className={cx(menu(menuProps).label)}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
...allOther
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuSeparatorProps = MergeOmitting<
|
||||||
|
Omit<useRender.ComponentProps<'div'>, 'children'>,
|
||||||
|
MenuVariantProps
|
||||||
|
>
|
||||||
|
|
||||||
|
export const MenuSeparator: FC<MenuSeparatorProps> = ({
|
||||||
|
render,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [menuProps, allOther] = menu.splitVariantProps(props)
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
render,
|
||||||
|
props: { className: cx(menu(menuProps).separator, className), ...allOther }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
@@ -52,15 +52,14 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute
|
IndexRoute: IndexRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
import type { createStart } from '@tanstack/react-start'
|
|
||||||
import type { getRouter } from './router.tsx'
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { createStart } from '@tanstack/react-start'
|
||||||
declare module '@tanstack/react-start' {
|
declare module '@tanstack/react-start' {
|
||||||
interface Register {
|
interface Register {
|
||||||
ssr: true
|
ssr: true
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import ORBITRON from '@fontsource/orbitron/900.css?url'
|
||||||
|
import ROBOTO from '@fontsource-variable/roboto?url'
|
||||||
|
import { config } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
import FONTAWESOME_STYLES from '@fortawesome/fontawesome-svg-core/styles.css?url'
|
||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
HeadContent,
|
HeadContent,
|
||||||
@@ -6,6 +11,12 @@ import {
|
|||||||
Scripts
|
Scripts
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import Footer from '@/components/layout/fragments/Footer'
|
||||||
|
import Header from '@/components/layout/fragments/Header'
|
||||||
|
import NotFound from '@/components/NotFound'
|
||||||
|
import GLOBAL_CSS from '@/styles/global.css?url'
|
||||||
|
|
||||||
|
config.autoAddCss = false
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -19,10 +30,60 @@ export const Route = createRootRoute({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Juggernaut Plays Blog'
|
title: 'Juggernaut Plays Blog'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Juego con cosas y luego hablo de ello.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: GLOBAL_CSS
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: ROBOTO
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: ORBITRON
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: FONTAWESOME_STYLES
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
href: '/favicon.svg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/x-icon',
|
||||||
|
href: '/favicon.ico'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '16x16',
|
||||||
|
href: '/favicon-16x16.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '32x32',
|
||||||
|
href: '/favicon-32x32.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'apple-touch-icon',
|
||||||
|
sizes: '180x180',
|
||||||
|
href: '/favicon-apple-touch.png'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
component: RootComponent
|
component: RootComponent,
|
||||||
|
notFoundComponent: NotFound
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
@@ -40,7 +101,9 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
|||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
|
import { css, cx } from '@styled-system/css'
|
||||||
|
import { skeleton } from '@styled-system/patterns'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import SrJuggernautLogo from '@/components/assets/SrJuggernautLogo'
|
||||||
|
import FullWidth from '@/components/layout/FullWidth'
|
||||||
|
|
||||||
const HomeRoute = () => {
|
const HomeRoute = () => {
|
||||||
return <div>Home</div>
|
return (
|
||||||
|
<FullWidth
|
||||||
|
className={cx(
|
||||||
|
skeleton({ duration: 4.5, variant: 'shimmerRight' }),
|
||||||
|
css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center'
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SrJuggernautLogo
|
||||||
|
className={css({
|
||||||
|
fill: 'neutral.12',
|
||||||
|
minWidth: '200px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: { sm: 'breakpoint-sm' }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<h1>Work in progress</h1>
|
||||||
|
</FullWidth>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
|
|||||||
1
src/styles/global.css
Normal file
1
src/styles/global.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@layer reset, base, tokens, recipes, utilities;
|
||||||
15
src/styles/srOnly.ts
Normal file
15
src/styles/srOnly.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { css } from '@styled-system/css'
|
||||||
|
|
||||||
|
const srOnlyClass = css({
|
||||||
|
position: 'absolute',
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0, 0, 0, 0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export default srOnlyClass
|
||||||
19
src/styles/theme.ts
Normal file
19
src/styles/theme.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ThemeConfig } from '@srjuggernaut-dev/srjuggernaut-panda-preset'
|
||||||
|
|
||||||
|
export const breakpoints = {
|
||||||
|
sm: 576,
|
||||||
|
md: 768,
|
||||||
|
lg: 992,
|
||||||
|
xl: 1200,
|
||||||
|
'2xl': 1400
|
||||||
|
} satisfies ThemeConfig['breakpoints']
|
||||||
|
|
||||||
|
export const themeConfig: ThemeConfig = {
|
||||||
|
neutral: 'slate',
|
||||||
|
colorVariation: { dark: true, alpha: false, p3: false },
|
||||||
|
includeColors: ['teal', 'slate'],
|
||||||
|
semanticColors: { primary: 'teal' },
|
||||||
|
breakpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
export default themeConfig
|
||||||
5
src/types/helpers.ts
Normal file
5
src/types/helpers.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type MergeOmitting<ReplaceableType, ReplacerType> = Omit<
|
||||||
|
ReplaceableType,
|
||||||
|
keyof ReplacerType
|
||||||
|
> &
|
||||||
|
ReplacerType
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
// Paths
|
// Paths
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@styled-system/*": ["./styled-system/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tsConfigPaths(), tanstackStart(), react()]
|
resolve: {
|
||||||
|
tsconfigPaths: true
|
||||||
|
},
|
||||||
|
plugins: [tanstackStart(), react()]
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user