Compare commits
19 Commits
de91f39231
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63481b4a4c | |||
| 1e03ca57b9 | |||
| 5c9e967163 | |||
| 4187c3bd80 | |||
| b0617d89e8 | |||
| e0c9d7c336 | |||
| a52327611b | |||
| a75239f4a4 | |||
| f42c4b8e0a | |||
| 9b8b8de875 | |||
| 2fc7de2cc8 | |||
| 7155f55d73 | |||
| 5ffb305bb0 | |||
| 1771924c69 | |||
| cb7e4d3449 | |||
| a6642bc2cc | |||
| 19191eac7c | |||
| 9589f3fa76 | |||
| a475d4c5a0 |
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
|
||||||
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",
|
||||||
|
|||||||
27
package.json
27
package.json
@@ -9,33 +9,32 @@
|
|||||||
"prepare": "panda codegen && 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",
|
||||||
"@pandacss/dev": "^1.8.2",
|
"@pandacss/dev": "^1.9.1",
|
||||||
"@types/bun": "latest",
|
"@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": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
"@fontsource-variable/roboto": "^5.2.10",
|
"@fontsource-variable/roboto": "^5.2.10",
|
||||||
"@fontsource/orbitron": "^5.2.8",
|
"@fontsource/orbitron": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^3.3.0",
|
||||||
"@srjuggernaut-dev/srjuggernaut-panda-preset": "^0.0.17",
|
"@srjuggernaut-dev/srjuggernaut-panda-preset": "^0.0.17",
|
||||||
"@tanstack/react-router": "^1.163.3",
|
"@tanstack/react-router": "^1.168.2",
|
||||||
"@tanstack/react-start": "^1.166.1",
|
"@tanstack/react-start": "^1.167.5",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ 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'
|
import GLOBAL_CSS from '@/styles/global.css?url'
|
||||||
|
|
||||||
config.autoAddCss = false
|
config.autoAddCss = false
|
||||||
@@ -79,7 +82,8 @@ export const Route = createRootRoute({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
component: RootComponent
|
component: RootComponent,
|
||||||
|
notFoundComponent: NotFound
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
@@ -97,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>
|
||||||
|
|||||||
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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,14 +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 { nitro } from 'nitro/vite'
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
resolve: {
|
||||||
tsConfigPaths(),
|
tsconfigPaths: true
|
||||||
nitro({ preset: 'bun', devServer: { port: 5173 } }),
|
},
|
||||||
tanstackStart(),
|
plugins: [tanstackStart(), react()]
|
||||||
react()
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user