Compare commits

..

19 Commits

Author SHA1 Message Date
63481b4a4c feat: add Not Found component 2026-03-23 11:58:58 -06:00
1e03ca57b9 refactor: move Header and Footer to root layout 2026-03-23 11:58:04 -06:00
5c9e967163 feat: add Header component to FullWidth layout 2026-03-23 10:29:33 -06:00
4187c3bd80 feat: add Menu component with sub-components 2026-03-23 10:29:16 -06:00
b0617d89e8 feat: add Header and MainMenu components 2026-03-23 10:28:35 -06:00
e0c9d7c336 chore: update dependencies 2026-03-23 09:29:49 -06:00
a52327611b chore: update dependencies 2026-03-23 08:53:40 -06:00
a75239f4a4 feat: integrate @base-ui/react 2026-03-20 13:11:13 -06:00
f42c4b8e0a feat: add reusable Button component 2026-03-20 11:40:07 -06:00
9b8b8de875 feat: add srOnly class 2026-03-20 11:39:32 -06:00
2fc7de2cc8 chore: update dependencies 2026-03-20 10:29:53 -06:00
7155f55d73 fix: add Footer import to FullWidth component 2026-03-17 16:56:30 -06:00
5ffb305bb0 feat: add footer component to full-width layout 2026-03-17 16:44:54 -06:00
1771924c69 chore: upgrade Vite to v8 and update React plugins 2026-03-17 16:27:19 -06:00
cb7e4d3449 feat: add graceful shutdown handlers for SIGINT and SIGTERM 2026-03-09 14:16:28 -06:00
a6642bc2cc feat: add Docker configuration 2026-03-09 14:13:23 -06:00
19191eac7c chore: move vite-tsconfig-paths to dependencies
Moved vite-tsconfig-paths from devDependencies to dependencies as it is required at runtime for path resolution in the Vite build.
2026-03-09 12:19:27 -06:00
9589f3fa76 chore: update dependencies 2026-03-09 12:10:39 -06:00
a475d4c5a0 feat: changes deployment method from nitro to bun native server 2026-03-09 12:10:24 -06:00
16 changed files with 1532 additions and 382 deletions

40
.dockerignore Normal file
View 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
View 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"]

View File

@@ -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": {
"enabled": true,
"clientKind": "git",

619
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -9,33 +9,32 @@
"prepare": "panda codegen && bun run ./.husky/install.mts"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@pandacss/dev": "^1.8.2",
"@types/bun": "latest",
"@biomejs/biome": "2.4.8",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@pandacss/dev": "^1.9.1",
"@types/bun": "1.3.11",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
"lint-staged": "^16.4.0",
"vite": "^8.0.2"
},
"peerDependencies": {
"typescript": "^5"
"typescript": "^5.9.3"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/roboto": "^5.2.10",
"@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.2.0",
"@fortawesome/react-fontawesome": "^3.3.0",
"@srjuggernaut-dev/srjuggernaut-panda-preset": "^0.0.17",
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-start": "^1.166.1",
"@vitejs/plugin-react-swc": "^4.2.3",
"nitro": "npm:nitro-nightly@latest",
"@tanstack/react-router": "^1.168.2",
"@tanstack/react-start": "^1.167.5",
"@vitejs/plugin-react": "^6.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}

566
server.ts Normal file
View 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)
})

View 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

View 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

View 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

View 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

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

View File

@@ -11,6 +11,9 @@ import {
Scripts
} from '@tanstack/react-router'
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
@@ -79,7 +82,8 @@ export const Route = createRootRoute({
}
]
}),
component: RootComponent
component: RootComponent,
notFoundComponent: NotFound
})
function RootComponent() {
@@ -97,7 +101,9 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
<HeadContent />
</head>
<body>
<Header />
{children}
<Footer />
<Scripts />
</body>
</html>

15
src/styles/srOnly.ts Normal file
View 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

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"target": "ES2022",
"module": "ESNext",
"moduleDetection": "force",

View File

@@ -1,14 +1,10 @@
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react-swc'
import { nitro } from 'nitro/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [
tsConfigPaths(),
nitro({ preset: 'bun', devServer: { port: 5173 } }),
tanstackStart(),
react()
]
resolve: {
tsconfigPaths: true
},
plugins: [tanstackStart(), react()]
})