Build and push app
This commit is contained in:
commit
c1c56a1f3e
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@ -0,0 +1,24 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Helm chart (not needed inside the image)
|
||||
helm/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Env files (never bake secrets into the image)
|
||||
.env
|
||||
.env.*
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
cloudflare-my-apps-tunnel.yaml
|
||||
65
.woodpecker.yml
Normal file
65
.woodpecker.yml
Normal file
@ -0,0 +1,65 @@
|
||||
steps:
|
||||
build:
|
||||
name: Build & Push
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push, pull_request, tag ]
|
||||
settings:
|
||||
registry: harbor.dvirlabs.com
|
||||
repo: my-apps/${CI_REPO_NAME}
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_PASSWORD
|
||||
|
||||
update-values:
|
||||
name: Update image tag in values.yaml
|
||||
image: alpine:3.19
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push ]
|
||||
environment:
|
||||
GIT_USERNAME:
|
||||
from_secret: GIT_USERNAME
|
||||
GIT_TOKEN:
|
||||
from_secret: GIT_TOKEN
|
||||
commands:
|
||||
- apk add --no-cache git yq
|
||||
- git config --global user.name "woodpecker-bot"
|
||||
- git config --global user.email "ci@dvirlabs.com"
|
||||
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
|
||||
- cd my-apps
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
echo "💡 Setting image tag to: $TAG"
|
||||
yq -i ".image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||
git commit -m "ci(${CI_REPO_NAME}): update image tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
|
||||
trigger-gitops-via-push:
|
||||
name: Trigger apps-gitops via Git push
|
||||
image: alpine/git
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push ]
|
||||
environment:
|
||||
GIT_USERNAME:
|
||||
from_secret: GIT_USERNAME
|
||||
GIT_TOKEN:
|
||||
from_secret: GIT_TOKEN
|
||||
commands:
|
||||
- git config --global user.name "woodpecker-bot"
|
||||
- git config --global user.email "ci@dvirlabs.com"
|
||||
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
|
||||
- cd apps-gitops
|
||||
- echo "# trigger at $(date) by ${CI_REPO_NAME}" >> .trigger
|
||||
- git add .trigger
|
||||
- git commit -m "ci: trigger apps-gitops build" || echo "no changes"
|
||||
- git push origin HEAD
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@ -0,0 +1,39 @@
|
||||
# ── Stage 1: build ───────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first (layer-cached unless package files change)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: serve ───────────────────────────────────────────────────────────
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Drop our custom config
|
||||
COPY nginx.conf /etc/nginx/conf.d/errorlab.conf
|
||||
|
||||
# Copy built static files from the builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Run as non-root (nginx:alpine UID 101)
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chown -R nginx:nginx /var/cache/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown nginx:nginx /var/run/nginx.pid
|
||||
|
||||
USER nginx
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost/index.html || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
67
dist/assets/index-BmIETw7i.js
vendored
Normal file
67
dist/assets/index-BmIETw7i.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DmKezAnz.css
vendored
Normal file
1
dist/assets/index-DmKezAnz.css
vendored
Normal file
File diff suppressed because one or more lines are too long
14
dist/index.html
vendored
Normal file
14
dist/index.html
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080c14" />
|
||||
<title>ErrorLab</title>
|
||||
<script type="module" crossorigin src="/assets/index-BmIETw7i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DmKezAnz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
15
helm/errorlab/Chart.yaml
Normal file
15
helm/errorlab/Chart.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
apiVersion: v2
|
||||
name: errorlab
|
||||
description: Custom public error and landing pages for the home lab
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.0.0"
|
||||
keywords:
|
||||
- errorlab
|
||||
- '404'
|
||||
- homelab
|
||||
- devops
|
||||
home: ""
|
||||
sources: []
|
||||
maintainers:
|
||||
- name: dvirl
|
||||
49
helm/errorlab/templates/_helpers.tpl
Normal file
49
helm/errorlab/templates/_helpers.tpl
Normal file
@ -0,0 +1,49 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "errorlab.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "errorlab.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart label (name + version).
|
||||
*/}}
|
||||
{{- define "errorlab.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels applied to every resource.
|
||||
*/}}
|
||||
{{- define "errorlab.labels" -}}
|
||||
helm.sh/chart: {{ include "errorlab.chart" . }}
|
||||
{{ include "errorlab.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels (used in spec.selector.matchLabels and pod template labels).
|
||||
*/}}
|
||||
{{- define "errorlab.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "errorlab.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
59
helm/errorlab/templates/deployment.yaml
Normal file
59
helm/errorlab/templates/deployment.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "errorlab.fullname" . }}
|
||||
labels:
|
||||
{{- include "errorlab.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "errorlab.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "errorlab.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
22
helm/errorlab/templates/hpa.yaml
Normal file
22
helm/errorlab/templates/hpa.yaml
Normal file
@ -0,0 +1,22 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "errorlab.fullname" . }}
|
||||
labels:
|
||||
{{- include "errorlab.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "errorlab.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
35
helm/errorlab/templates/ingress.yaml
Normal file
35
helm/errorlab/templates/ingress.yaml
Normal file
@ -0,0 +1,35 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "errorlab.fullname" . }}
|
||||
labels:
|
||||
{{- include "errorlab.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "errorlab.fullname" $ }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
15
helm/errorlab/templates/service.yaml
Normal file
15
helm/errorlab/templates/service.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "errorlab.fullname" . }}
|
||||
labels:
|
||||
{{- include "errorlab.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "errorlab.selectorLabels" . | nindent 4 }}
|
||||
87
helm/errorlab/values.yaml
Normal file
87
helm/errorlab/values.yaml
Normal file
@ -0,0 +1,87 @@
|
||||
# ── Replica count ─────────────────────────────────────────────────────────────
|
||||
replicaCount: 1
|
||||
|
||||
# ── Image ─────────────────────────────────────────────────────────────────────
|
||||
image:
|
||||
repository: errorlab # override with your registry, e.g. ghcr.io/dvirl/errorlab
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────────────
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
# ── Ingress ───────────────────────────────────────────────────────────────────
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx # set to your ingress class (e.g. "nginx", "traefik")
|
||||
annotations: {}
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: errorlab.lab # replace with your actual hostname
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: errorlab-tls
|
||||
# hosts:
|
||||
# - errorlab.lab
|
||||
|
||||
# ── Resources ─────────────────────────────────────────────────────────────────
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
|
||||
# ── Pod settings ──────────────────────────────────────────────────────────────
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101 # nginx user in nginx:alpine
|
||||
fsGroup: 101
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false # nginx needs to write to /var/cache/nginx
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# ── Probes ────────────────────────────────────────────────────────────────────
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 20
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
|
||||
# ── Autoscaling (disabled by default for a static site) ───────────────────────
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
targetCPUUtilizationPercentage: 80
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080c14" />
|
||||
<title>ErrorLab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
nginx.conf
Normal file
35
nginx.conf
Normal file
@ -0,0 +1,35 @@
|
||||
# nginx configuration for a React SPA (React Router support)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml image/x-icon font/woff2;
|
||||
|
||||
# Long-lived cache for hashed assets (Vite outputs hash in filename)
|
||||
location ~* \.(?:js|css|woff2?|ttf|ico|png|jpg|jpeg|gif|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# index.html — no cache; browser always re-validates
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# SPA fallback — any unknown path serves index.html so React Router works
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hide nginx version in error responses
|
||||
server_tokens off;
|
||||
}
|
||||
1719
package-lock.json
generated
Normal file
1719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "errorlab",
|
||||
"version": "1.0.0",
|
||||
"description": "Custom public error and landing pages for the home lab",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
19
src/App.jsx
Normal file
19
src/App.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import NotFoundPage from './pages/404/NotFoundPage.jsx'
|
||||
import BadGatewayPage from './pages/502/BadGatewayPage.jsx'
|
||||
import MaintenancePage from './pages/maintenance/MaintenancePage.jsx'
|
||||
import HomePage from './pages/home/HomePage.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/502" element={<BadGatewayPage />} />
|
||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||
{/* Catch-all — must be last */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
8
src/assets/README.md
Normal file
8
src/assets/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# assets/
|
||||
|
||||
Place static files here: icons, images, SVGs, fonts, etc.
|
||||
|
||||
Examples:
|
||||
- favicon.svg
|
||||
- logo.svg
|
||||
- og-image.png
|
||||
20
src/components/DinoGame/DinoGame.css
Normal file
20
src/components/DinoGame/DinoGame.css
Normal file
@ -0,0 +1,20 @@
|
||||
/* DinoGame canvas wrapper */
|
||||
|
||||
.dino-game-wrapper {
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.dino-game-canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: 1px solid rgba(0, 200, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.5),
|
||||
0 0 32px rgba(0, 200, 255, 0.08);
|
||||
}
|
||||
416
src/components/DinoGame/DinoGame.jsx
Normal file
416
src/components/DinoGame/DinoGame.jsx
Normal file
@ -0,0 +1,416 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import './DinoGame.css'
|
||||
import {
|
||||
CANVAS_W, CANVAS_H, GROUND_Y,
|
||||
GRAVITY, JUMP_VEL, INITIAL_SPEED, SPEED_GROWTH, MAX_SPEED,
|
||||
PLAYER_X, PLAYER_W, PLAYER_H,
|
||||
MIN_GAP, MAX_GAP,
|
||||
OBSTACLE_TEMPLATES,
|
||||
} from './gameConstants.js'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function randObstacle() {
|
||||
const t = OBSTACLE_TEMPLATES[Math.floor(Math.random() * OBSTACLE_TEMPLATES.length)]
|
||||
return { ...t, x: CANVAS_W + 60, y: GROUND_Y - t.h }
|
||||
}
|
||||
|
||||
/** AABB collision with per-axis inward padding for forgiveness */
|
||||
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh, pad = 7) {
|
||||
return (
|
||||
ax + pad < bx + bw - pad &&
|
||||
ax + aw - pad > bx + pad &&
|
||||
ay + pad < by + bh - pad &&
|
||||
ay + ah - pad > by + pad
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Canvas drawing ───────────────────────────────────────────────────────────
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r = 5) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.lineTo(x + w - r, y)
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||
ctx.lineTo(x + w, y + h - r)
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||
ctx.lineTo(x + r, y + h)
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||
ctx.lineTo(x, y + r)
|
||||
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawBackground(ctx, bgOffset) {
|
||||
// Fill
|
||||
ctx.fillStyle = '#080c14'
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
||||
|
||||
// Scrolling dot grid
|
||||
ctx.fillStyle = 'rgba(0, 180, 255, 0.055)'
|
||||
const spacing = 28
|
||||
const startX = (-bgOffset % spacing) - spacing
|
||||
for (let x = startX; x < CANVAS_W + spacing; x += spacing) {
|
||||
for (let y = 10; y < GROUND_Y - 4; y += spacing) {
|
||||
ctx.fillRect(x, y, 1.5, 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGround(ctx, bgOffset) {
|
||||
// Glowing ground line
|
||||
ctx.save()
|
||||
ctx.shadowBlur = 8
|
||||
ctx.shadowColor = '#00ccff'
|
||||
ctx.strokeStyle = '#00ccff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, GROUND_Y)
|
||||
ctx.lineTo(CANVAS_W, GROUND_Y)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
// Scrolling dashes below ground line
|
||||
ctx.save()
|
||||
ctx.strokeStyle = 'rgba(0, 140, 200, 0.35)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([18, 14])
|
||||
ctx.lineDashOffset = -bgOffset
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, GROUND_Y + 7)
|
||||
ctx.lineTo(CANVAS_W, GROUND_Y + 7)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Pod player — kubernetes steering-wheel symbol inside a neon green box */
|
||||
function drawPlayer(ctx, playerY, frameCount, isJumping) {
|
||||
const x = PLAYER_X
|
||||
const y = playerY
|
||||
const w = PLAYER_W
|
||||
const h = PLAYER_H
|
||||
const cx = x + w / 2
|
||||
const cy = y + h / 2 - 1
|
||||
|
||||
ctx.save()
|
||||
|
||||
// Outer glow
|
||||
ctx.shadowBlur = 14
|
||||
ctx.shadowColor = '#00ff88'
|
||||
|
||||
// Body fill
|
||||
roundRect(ctx, x, y, w, h, 5)
|
||||
ctx.fillStyle = '#081410'
|
||||
ctx.fill()
|
||||
|
||||
// Body border
|
||||
ctx.strokeStyle = '#00ff88'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
// Kubernetes helm wheel (6 spokes + outer ring)
|
||||
ctx.strokeStyle = '#00ff88'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i - Math.PI / 6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + Math.cos(a) * 3, cy + Math.sin(a) * 3)
|
||||
ctx.lineTo(cx + Math.cos(a) * 9, cy + Math.sin(a) * 9)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, 10, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// Centre dot
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, 2.5, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#00ff88'
|
||||
ctx.fill()
|
||||
|
||||
// Running legs (2-frame animation, hidden while airborne)
|
||||
if (!isJumping) {
|
||||
const phase = Math.floor(frameCount / 6) % 2
|
||||
ctx.fillStyle = '#00ff88'
|
||||
// Left leg
|
||||
const lh = phase === 0 ? 8 : 4
|
||||
ctx.fillRect(x + 7, y + h, 6, lh)
|
||||
ctx.fillRect(x + 3, y + h + lh, 10, 3)
|
||||
// Right leg (opposite phase)
|
||||
const rh = phase === 0 ? 4 : 8
|
||||
ctx.fillRect(x + w - 13, y + h, 6, rh)
|
||||
ctx.fillRect(x + w - 17, y + h + rh, 10, 3)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawObstacle(ctx, obs) {
|
||||
const { x, y, w, h, lines, color } = obs
|
||||
const cx = x + w / 2
|
||||
const cy = y + h / 2
|
||||
|
||||
ctx.save()
|
||||
|
||||
// Diagonal warning stripes clipped to obstacle rect
|
||||
roundRect(ctx, x, y, w, h, 4)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.82)'
|
||||
ctx.fill()
|
||||
|
||||
ctx.save()
|
||||
roundRect(ctx, x, y, w, h, 4)
|
||||
ctx.clip()
|
||||
// Translucent diagonal stripes using 8-digit hex alpha
|
||||
ctx.strokeStyle = `${color}22`
|
||||
ctx.lineWidth = 5
|
||||
for (let s = -h; s < w + h; s += 10) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + s, y)
|
||||
ctx.lineTo(x + s - h, y + h)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// Glowing border
|
||||
ctx.shadowBlur = 14
|
||||
ctx.shadowColor = color
|
||||
roundRect(ctx, x, y, w, h, 4)
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
// Label text
|
||||
ctx.fillStyle = color
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
if (lines.length === 1) {
|
||||
ctx.font = 'bold 10px monospace'
|
||||
ctx.fillText(lines[0], cx, cy)
|
||||
} else {
|
||||
ctx.font = 'bold 9px monospace'
|
||||
ctx.fillText(lines[0], cx, cy - 6)
|
||||
ctx.fillText(lines[1], cx, cy + 6)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawHUD(ctx, score, highScore) {
|
||||
// Score
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#00ccff'
|
||||
ctx.font = 'bold 13px monospace'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, CANVAS_W - 14, 20)
|
||||
|
||||
// High score
|
||||
ctx.fillStyle = 'rgba(0,200,255,0.42)'
|
||||
ctx.font = '11px monospace'
|
||||
ctx.fillText(`HI ${String(highScore).padStart(5, '0')}`, CANVAS_W - 14, 36)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawIdleOverlay(ctx) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(8, 12, 20, 0.72)'
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
||||
ctx.fillStyle = '#00ffcc'
|
||||
ctx.font = 'bold 15px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('[ SPACE / ↑ / TAP TO LAUNCH ]', CANVAS_W / 2, CANVAS_H / 2 - 8)
|
||||
ctx.fillStyle = 'rgba(0,220,255,0.5)'
|
||||
ctx.font = '11px monospace'
|
||||
ctx.fillText('dodge the cluster faults', CANVAS_W / 2, CANVAS_H / 2 + 12)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawGameOverOverlay(ctx, score, highScore) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(8, 12, 20, 0.72)'
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
||||
|
||||
ctx.fillStyle = '#ff4455'
|
||||
ctx.font = 'bold 17px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('── POD TERMINATED ──', CANVAS_W / 2, CANVAS_H / 2 - 26)
|
||||
|
||||
ctx.fillStyle = '#e0e8f0'
|
||||
ctx.font = '13px monospace'
|
||||
ctx.fillText(`score: ${score}`, CANVAS_W / 2, CANVAS_H / 2 - 2)
|
||||
|
||||
if (score > 0 && score >= highScore) {
|
||||
ctx.fillStyle = '#ffee22'
|
||||
ctx.font = 'bold 11px monospace'
|
||||
ctx.fillText('✦ new high score ✦', CANVAS_W / 2, CANVAS_H / 2 + 16)
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#00ffcc'
|
||||
ctx.font = '12px monospace'
|
||||
ctx.fillText('[ SPACE / ↑ / TAP to respawn ]', CANVAS_W / 2, CANVAS_H / 2 + 38)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* DinoGame — canvas-based infinite runner with a Kubernetes pod player
|
||||
* and DevOps-themed obstacles.
|
||||
*
|
||||
* Controls: Space / ArrowUp / click / tap to jump (or start / restart).
|
||||
*/
|
||||
export default function DinoGame() {
|
||||
const canvasRef = useRef(null)
|
||||
|
||||
// All mutable game state lives in a ref so the rAF loop never re-renders
|
||||
const s = useRef({
|
||||
status: 'idle', // 'idle' | 'playing' | 'gameover'
|
||||
playerY: GROUND_Y - PLAYER_H,
|
||||
velocityY: 0,
|
||||
isJumping: false,
|
||||
obstacles: [],
|
||||
bgOffset: 0,
|
||||
frameCount: 0, // frames while playing (used for animation & score)
|
||||
distTravelled: 0, // pixels travelled (used for obstacle spawning)
|
||||
nextSpawnAt: 400, // distTravelled value at which to spawn next obstacle
|
||||
speed: INITIAL_SPEED,
|
||||
score: 0,
|
||||
highScore: parseInt(localStorage.getItem('lab404_hi') ?? '0', 10),
|
||||
})
|
||||
|
||||
// Minimal React state — only for values shown in JSX buttons outside canvas
|
||||
const [gameStatus, setGameStatus] = useState('idle')
|
||||
|
||||
// ── Input handler ─────────────────────────────────────────────────────────
|
||||
const handleAction = useCallback(() => {
|
||||
const g = s.current
|
||||
if (g.status === 'idle') {
|
||||
g.status = 'playing'
|
||||
setGameStatus('playing')
|
||||
} else if (g.status === 'playing' && !g.isJumping) {
|
||||
g.velocityY = JUMP_VEL
|
||||
g.isJumping = true
|
||||
} else if (g.status === 'gameover') {
|
||||
// Full reset
|
||||
g.status = 'playing'
|
||||
g.playerY = GROUND_Y - PLAYER_H
|
||||
g.velocityY = 0
|
||||
g.isJumping = false
|
||||
g.obstacles = []
|
||||
g.bgOffset = 0
|
||||
g.frameCount = 0
|
||||
g.distTravelled = 0
|
||||
g.nextSpawnAt = 400
|
||||
g.speed = INITIAL_SPEED
|
||||
g.score = 0
|
||||
setGameStatus('playing')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Keyboard listeners ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.code === 'Space' || e.code === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
handleAction()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [handleAction])
|
||||
|
||||
// ── Game loop ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
let rafId
|
||||
|
||||
const loop = () => {
|
||||
const g = s.current
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────
|
||||
if (g.status === 'playing') {
|
||||
g.frameCount++
|
||||
|
||||
// Speed ramp
|
||||
g.score = Math.floor(g.frameCount / 7)
|
||||
g.speed = Math.min(INITIAL_SPEED + g.score * SPEED_GROWTH, MAX_SPEED)
|
||||
|
||||
// Background scroll
|
||||
g.bgOffset = (g.bgOffset + g.speed * 0.35) % CANVAS_W
|
||||
g.distTravelled += g.speed
|
||||
|
||||
// Player physics
|
||||
g.velocityY += GRAVITY
|
||||
g.playerY += g.velocityY
|
||||
if (g.playerY >= GROUND_Y - PLAYER_H) {
|
||||
g.playerY = GROUND_Y - PLAYER_H
|
||||
g.velocityY = 0
|
||||
g.isJumping = false
|
||||
}
|
||||
|
||||
// Spawn obstacle when distance threshold is reached
|
||||
if (g.distTravelled >= g.nextSpawnAt) {
|
||||
g.obstacles.push(randObstacle())
|
||||
const gap = MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP)
|
||||
g.nextSpawnAt = g.distTravelled + gap
|
||||
}
|
||||
|
||||
// Move & cull obstacles
|
||||
for (const o of g.obstacles) o.x -= g.speed
|
||||
g.obstacles = g.obstacles.filter(o => o.x + o.w > -10)
|
||||
|
||||
// Collision detection
|
||||
const hit = g.obstacles.some(o =>
|
||||
rectsOverlap(PLAYER_X, g.playerY, PLAYER_W, PLAYER_H, o.x, o.y, o.w, o.h)
|
||||
)
|
||||
if (hit) {
|
||||
if (g.score > g.highScore) {
|
||||
g.highScore = g.score
|
||||
localStorage.setItem('lab404_hi', String(g.score))
|
||||
}
|
||||
g.status = 'gameover'
|
||||
setGameStatus('gameover')
|
||||
}
|
||||
} else {
|
||||
// Even while idle/gameover keep background subtly drifting
|
||||
g.bgOffset = (g.bgOffset + 0.4) % CANVAS_W
|
||||
}
|
||||
|
||||
// ── Draw ────────────────────────────────────────────────────────────
|
||||
drawBackground(ctx, g.bgOffset)
|
||||
drawGround(ctx, g.bgOffset)
|
||||
g.obstacles.forEach(o => drawObstacle(ctx, o))
|
||||
drawPlayer(ctx, g.playerY, g.frameCount, g.isJumping)
|
||||
drawHUD(ctx, g.score, g.highScore)
|
||||
|
||||
if (g.status === 'idle') drawIdleOverlay(ctx)
|
||||
if (g.status === 'gameover') drawGameOverOverlay(ctx, g.score, g.highScore)
|
||||
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(loop)
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, []) // runs once — game loop owns its own state via ref
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dino-game-wrapper"
|
||||
onClick={handleAction}
|
||||
onTouchStart={(e) => { e.preventDefault(); handleAction() }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_W}
|
||||
height={CANVAS_H}
|
||||
className="dino-game-canvas"
|
||||
role="img"
|
||||
aria-label="Kubernetes pod runner mini-game. Press Space or tap to jump."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/components/DinoGame/gameConstants.js
Normal file
39
src/components/DinoGame/gameConstants.js
Normal file
@ -0,0 +1,39 @@
|
||||
// ─── Canvas & World ───────────────────────────────────────────────────────────
|
||||
export const CANVAS_W = 800
|
||||
export const CANVAS_H = 220
|
||||
export const GROUND_Y = 168 // y of the ground surface
|
||||
|
||||
// ─── Physics ──────────────────────────────────────────────────────────────────
|
||||
export const GRAVITY = 0.58
|
||||
export const JUMP_VEL = -13.5
|
||||
export const INITIAL_SPEED = 5.2
|
||||
export const SPEED_GROWTH = 0.00055 // added per score point (score = frames / 7)
|
||||
export const MAX_SPEED = 16
|
||||
|
||||
// ─── Player ───────────────────────────────────────────────────────────────────
|
||||
export const PLAYER_X = 82
|
||||
export const PLAYER_W = 36
|
||||
export const PLAYER_H = 36
|
||||
|
||||
// ─── Obstacle spawning ────────────────────────────────────────────────────────
|
||||
export const MIN_GAP = 340 // minimum pixel gap between spawns (at game speed 1)
|
||||
export const MAX_GAP = 680
|
||||
|
||||
// ─── Obstacle visual templates ────────────────────────────────────────────────
|
||||
// Each obstacle has two display lines, a neon colour, and hit-box dimensions.
|
||||
export const OBSTACLE_TEMPLATES = [
|
||||
{ lines: ['Crash', 'Loop'], color: '#ff4455', w: 46, h: 52 },
|
||||
{ lines: ['OOM', 'Killed'], color: '#ff2288', w: 38, h: 60 },
|
||||
{ lines: ['Image', 'PullErr'], color: '#ff8820', w: 44, h: 46 },
|
||||
{ lines: ['404'], color: '#00ffff', w: 58, h: 36 },
|
||||
{ lines: ['Expired', 'Cert'], color: '#ffee22', w: 48, h: 48 },
|
||||
{ lines: ['Evicted'], color: '#aaaaaa', w: 40, h: 34 },
|
||||
{ lines: ['Pod', 'Failed'], color: '#ff3366', w: 40, h: 44 },
|
||||
{ lines: ['Bad', 'Ingress'], color: '#ff6600', w: 50, h: 40 },
|
||||
{ lines: ['DNS', 'Fail'], color: '#cc44ff', w: 42, h: 50 },
|
||||
{ lines: ['Pending', '...'], color: '#4499ff', w: 52, h: 32 },
|
||||
{ lines: ['Timeout'], color: '#ff9944', w: 48, h: 42 },
|
||||
{ lines: ['502'], color: '#ff4444', w: 52, h: 36 },
|
||||
{ lines: ['No', 'Route'], color: '#55ff88', w: 46, h: 44 },
|
||||
{ lines: ['Tunnel', 'Down'], color: '#ff55cc', w: 50, h: 46 },
|
||||
]
|
||||
34
src/components/PageLayout/PageLayout.css
Normal file
34
src/components/PageLayout/PageLayout.css
Normal file
@ -0,0 +1,34 @@
|
||||
/* PageLayout — full-viewport dark shell used by all pages */
|
||||
|
||||
.page-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle animated star field in the background */
|
||||
.page-layout::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 15% 20%, rgba(0,200,255,0.18) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 42% 8%, rgba(0,255,204,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 70% 35%, rgba(0,200,255,0.15) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 88% 60%, rgba(0,255,204,0.10) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 25% 75%, rgba(0,200,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 55% 90%, rgba(0,255,204,0.08) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.page-layout > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
17
src/components/PageLayout/PageLayout.jsx
Normal file
17
src/components/PageLayout/PageLayout.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import './PageLayout.css'
|
||||
|
||||
/**
|
||||
* PageLayout — shared full-viewport wrapper for all public pages.
|
||||
* Applies the dark background and centres content vertically.
|
||||
*
|
||||
* Props:
|
||||
* children — page content
|
||||
* className — optional extra class on the root element
|
||||
*/
|
||||
export default function PageLayout({ children, className = '' }) {
|
||||
return (
|
||||
<div className={`page-layout ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/components/TerminalLog/TerminalLog.css
Normal file
82
src/components/TerminalLog/TerminalLog.css
Normal file
@ -0,0 +1,82 @@
|
||||
/* TerminalLog — fake cluster-diagnostics terminal window */
|
||||
|
||||
.terminal-log {
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 24px rgba(0, 180, 255, 0.07);
|
||||
}
|
||||
|
||||
/* Title bar */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.terminal-dot.red { background: #ff5f57; }
|
||||
.terminal-dot.yellow { background: #febc2e; }
|
||||
.terminal-dot.green { background: #28c840; }
|
||||
|
||||
.terminal-title {
|
||||
margin-left: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Log body */
|
||||
.terminal-body {
|
||||
padding: 12px 16px 10px;
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Type colour coding */
|
||||
.terminal-line--cmd { color: var(--accent); }
|
||||
.terminal-line--info { color: var(--cyan-dim); }
|
||||
.terminal-line--warn { color: var(--warning); }
|
||||
.terminal-line--err { color: var(--danger); }
|
||||
|
||||
/* Slide-in animation for the newest line */
|
||||
@keyframes slideInLine {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.terminal-line--new {
|
||||
animation: slideInLine 0.3s ease-out both;
|
||||
}
|
||||
|
||||
/* Blinking cursor */
|
||||
.terminal-cursor {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
animation: blink 1.1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
77
src/components/TerminalLog/TerminalLog.jsx
Normal file
77
src/components/TerminalLog/TerminalLog.jsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import './TerminalLog.css'
|
||||
|
||||
/** Rotating pool of cluster-flavoured diagnostic messages */
|
||||
const MESSAGE_POOL = [
|
||||
{ type: 'cmd', text: '$ kubectl get page /this-path --namespace default' },
|
||||
{ type: 'err', text: 'Error from server (NotFound): pages "this-path" not found' },
|
||||
{ type: 'cmd', text: '$ kubectl describe ingress/main-ingress' },
|
||||
{ type: 'warn', text: '[WARN] ingress-nginx: no matching rule for path "/this-path"' },
|
||||
{ type: 'cmd', text: '$ nslookup this-page.cluster.local' },
|
||||
{ type: 'err', text: 'NXDOMAIN: Name does not exist.' },
|
||||
{ type: 'cmd', text: '$ kubectl get pod -l app=this-page' },
|
||||
{ type: 'err', text: 'No resources found in default namespace.' },
|
||||
{ type: 'warn', text: '[WARN] cloudflared: tunnel endpoint unreachable' },
|
||||
{ type: 'info', text: '[INFO] attempting to reschedule pod...' },
|
||||
{ type: 'err', text: '[ERROR] ImagePullBackOff: image "this-page:latest" not found' },
|
||||
{ type: 'cmd', text: '$ kubectl rollout status deploy/this-page' },
|
||||
{ type: 'err', text: 'error: deployment "this-page" not found' },
|
||||
{ type: 'warn', text: '[WARN] certificate for this-page.lab expired 42 days ago' },
|
||||
{ type: 'info', text: '[INFO] DNS TTL 30s — retrying...' },
|
||||
{ type: 'err', text: '[ERROR] upstream "this-page:8080": connection refused' },
|
||||
{ type: 'cmd', text: '$ kubectl logs -l app=this-page --tail=5' },
|
||||
{ type: 'err', text: 'Error from server (NotFound): pods "this-page" not found' },
|
||||
{ type: 'warn', text: '[WARN] 3 consecutive CrashLoopBackOff events detected' },
|
||||
{ type: 'info', text: '[INFO] route table: /this-path → ??? (unresolved)' },
|
||||
]
|
||||
|
||||
const MAX_VISIBLE_LINES = 6
|
||||
const TICK_MS = 1700
|
||||
|
||||
/**
|
||||
* TerminalLog — auto-scrolling fake terminal showing rotating cluster errors.
|
||||
* title — override the window title bar text (default: "cluster-diagnostics")
|
||||
*/
|
||||
export default function TerminalLog({ title = 'cluster-diagnostics' }) {
|
||||
const [lines, setLines] = useState([MESSAGE_POOL[0]])
|
||||
const indexRef = useRef(1)
|
||||
const containerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const next = MESSAGE_POOL[indexRef.current % MESSAGE_POOL.length]
|
||||
indexRef.current += 1
|
||||
setLines(prev => [...prev, next].slice(-MAX_VISIBLE_LINES))
|
||||
}, TICK_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom whenever lines update
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
}, [lines])
|
||||
|
||||
return (
|
||||
<div className="terminal-log">
|
||||
<div className="terminal-header">
|
||||
<span className="terminal-dot red" />
|
||||
<span className="terminal-dot yellow" />
|
||||
<span className="terminal-dot green" />
|
||||
<span className="terminal-title">{title}</span>
|
||||
</div>
|
||||
<div className="terminal-body" ref={containerRef}>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`terminal-line terminal-line--${line.type}${i === lines.length - 1 ? ' terminal-line--new' : ''}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
<span className="terminal-cursor">▋</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/index.css
Normal file
50
src/index.css
Normal file
@ -0,0 +1,50 @@
|
||||
/* ── Global reset & dark theme base ─────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--bg: #080c14;
|
||||
--surface: #0d1520;
|
||||
--surface-alt: #111827;
|
||||
--border: rgba(0, 200, 255, 0.14);
|
||||
--accent: #00ffcc;
|
||||
--accent-dim: rgba(0, 255, 204, 0.25);
|
||||
--accent-glow: rgba(0, 255, 204, 0.35);
|
||||
--cyan: #00ccff;
|
||||
--cyan-dim: rgba(0, 200, 255, 0.5);
|
||||
--text: #e0e8f0;
|
||||
--text-muted: rgba(200, 220, 240, 0.45);
|
||||
--danger: #ff4455;
|
||||
--warning: #ffaa00;
|
||||
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: rgba(0, 200, 255, 0.25); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(0, 200, 255, 0.4); }
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
152
src/pages/404/NotFoundPage.css
Normal file
152
src/pages/404/NotFoundPage.css
Normal file
@ -0,0 +1,152 @@
|
||||
/* ── 404 Not Found Page ───────────────────────────────────────────────────── */
|
||||
|
||||
.nfp-container {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.nfp-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Glitch animation on the big 404 numeral */
|
||||
@keyframes glitch {
|
||||
0%, 88%, 100% { transform: none; text-shadow: -2px 0 #00ffcc, 2px 0 #ff4455; }
|
||||
90% { transform: translate(-2px, 0); color: #ff4455; }
|
||||
92% { transform: translate(2px, 0); color: #00ffcc; }
|
||||
94% { transform: translate(-1px, 0); }
|
||||
96% { transform: none; }
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.nfp-code {
|
||||
font-size: clamp(5.5rem, 18vw, 10.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2px var(--accent);
|
||||
text-shadow: -2px 0 #00ffcc, 2px 0 #ff4455, 0 0 60px rgba(0,255,204,0.25);
|
||||
animation:
|
||||
fadeInScale 0.7s ease-out both,
|
||||
glitch 5s ease-in-out 1.5s infinite;
|
||||
}
|
||||
|
||||
.nfp-title {
|
||||
font-size: clamp(1.1rem, 3vw, 1.6rem);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
animation: fadeInScale 0.7s 0.15s ease-out both;
|
||||
}
|
||||
|
||||
.nfp-subtitle {
|
||||
font-size: clamp(0.82rem, 2vw, 1rem);
|
||||
color: var(--cyan-dim);
|
||||
animation: fadeInScale 0.7s 0.3s ease-out both;
|
||||
}
|
||||
|
||||
.nfp-kubectl {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.8rem;
|
||||
animation: fadeInScale 0.7s 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.nfp-kubectl .err { color: var(--danger); }
|
||||
|
||||
/* ── Game section ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.nfp-game-section {
|
||||
width: 100%;
|
||||
animation: fadeInScale 0.7s 0.55s ease-out both;
|
||||
}
|
||||
|
||||
.nfp-game-hint {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Terminal log ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.nfp-log-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
animation: fadeInScale 0.7s 0.65s ease-out both;
|
||||
}
|
||||
|
||||
/* ── Footer buttons ───────────────────────────────────────────────────────── */
|
||||
|
||||
.nfp-footer {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
animation: fadeInScale 0.7s 0.75s ease-out both;
|
||||
}
|
||||
|
||||
.nfp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nfp-btn--primary {
|
||||
background: rgba(0, 255, 204, 0.1);
|
||||
border-color: rgba(0, 255, 204, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nfp-btn--primary:hover {
|
||||
background: rgba(0, 255, 204, 0.18);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 18px var(--accent-glow);
|
||||
}
|
||||
|
||||
.nfp-btn--secondary {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nfp-btn--secondary:hover {
|
||||
background: rgba(255,255,255,0.09);
|
||||
border-color: rgba(255,255,255,0.28);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nfp-container { gap: 1.5rem; padding: 1.5rem 1rem 2rem; }
|
||||
.nfp-footer { gap: 0.65rem; }
|
||||
.nfp-btn { font-size: 0.8rem; padding: 0.5rem 1.1rem; }
|
||||
}
|
||||
58
src/pages/404/NotFoundPage.jsx
Normal file
58
src/pages/404/NotFoundPage.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
|
||||
import DinoGame from '../../components/DinoGame/DinoGame.jsx'
|
||||
import TerminalLog from '../../components/TerminalLog/TerminalLog.jsx'
|
||||
import './NotFoundPage.css'
|
||||
|
||||
/**
|
||||
* 404 Not Found — the main interactive error page.
|
||||
* Features the Kubernetes pod runner game and a live cluster-diagnostics terminal.
|
||||
*/
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="nfp-container">
|
||||
|
||||
{/* ── Header ──────────────────────────────────────────────────────── */}
|
||||
<header className="nfp-header">
|
||||
<h1 className="nfp-code" aria-label="Error 404">404</h1>
|
||||
<h2 className="nfp-title">Page Not Found</h2>
|
||||
<p className="nfp-subtitle">
|
||||
This route was lost somewhere in the cluster.
|
||||
</p>
|
||||
<code className="nfp-kubectl">
|
||||
$ kubectl get page: <span className="err">NotFound</span>
|
||||
</code>
|
||||
</header>
|
||||
|
||||
{/* ── Mini-game ───────────────────────────────────────────────────── */}
|
||||
<section className="nfp-game-section" aria-label="Mini-game">
|
||||
<DinoGame />
|
||||
<p className="nfp-game-hint">
|
||||
space / ↑ / tap — jump · dodge the cluster faults
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Live "diagnostics" terminal ─────────────────────────────────── */}
|
||||
<section className="nfp-log-section" aria-label="Cluster diagnostics log">
|
||||
<TerminalLog title="cluster-diagnostics — 404" />
|
||||
</section>
|
||||
|
||||
{/* ── Navigation ──────────────────────────────────────────────────── */}
|
||||
<footer className="nfp-footer">
|
||||
<a href="/" className="nfp-btn nfp-btn--primary">
|
||||
← Go Home
|
||||
</a>
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/"
|
||||
className="nfp-btn nfp-btn--secondary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
kubectl describe pod
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
56
src/pages/502/BadGatewayPage.css
Normal file
56
src/pages/502/BadGatewayPage.css
Normal file
@ -0,0 +1,56 @@
|
||||
/* 502 Bad Gateway — shares the same visual language as the 404 page */
|
||||
|
||||
.bgp-container {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bgp-header { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.bgp-code {
|
||||
font-size: clamp(5rem, 18vw, 9rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2px var(--warning);
|
||||
text-shadow: -2px 0 var(--warning), 2px 0 var(--danger), 0 0 60px rgba(255,170,0,0.25);
|
||||
}
|
||||
|
||||
.bgp-title { font-size: clamp(1.1rem, 3vw, 1.5rem); font-weight: 700; color: var(--text); }
|
||||
.bgp-subtitle { font-size: 0.9rem; color: var(--cyan-dim); }
|
||||
.bgp-kubectl {
|
||||
font-size: 0.8rem; color: var(--text-muted);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 0.35rem 0.8rem;
|
||||
}
|
||||
.bgp-kubectl .err { color: var(--danger); }
|
||||
|
||||
.bgp-log-section { width: 100%; display: flex; justify-content: center; }
|
||||
|
||||
.bgp-footer { display: flex; gap: 0.9rem; flex-wrap: wrap; justify-content: center; }
|
||||
|
||||
.bgp-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
padding: 0.55rem 1.4rem; border-radius: 6px;
|
||||
font-family: var(--font-mono); font-size: 0.85rem; font-weight: 600;
|
||||
text-decoration: none; cursor: pointer;
|
||||
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.bgp-btn--primary {
|
||||
background: rgba(255,170,0,0.1); border-color: rgba(255,170,0,0.4); color: var(--warning);
|
||||
}
|
||||
.bgp-btn--primary:hover {
|
||||
background: rgba(255,170,0,0.18); border-color: var(--warning); box-shadow: 0 0 18px rgba(255,170,0,0.3);
|
||||
}
|
||||
.bgp-btn--secondary {
|
||||
background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.14); color: var(--text-muted);
|
||||
}
|
||||
.bgp-btn--secondary:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.28); color: var(--text); }
|
||||
37
src/pages/502/BadGatewayPage.jsx
Normal file
37
src/pages/502/BadGatewayPage.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
|
||||
import TerminalLog from '../../components/TerminalLog/TerminalLog.jsx'
|
||||
import './BadGatewayPage.css'
|
||||
|
||||
/**
|
||||
* 502 Bad Gateway — placeholder.
|
||||
* TODO: add a 502-themed mini-game or animation.
|
||||
*/
|
||||
export default function BadGatewayPage() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bgp-container">
|
||||
<header className="bgp-header">
|
||||
<h1 className="bgp-code" aria-label="Error 502">502</h1>
|
||||
<h2 className="bgp-title">Bad Gateway</h2>
|
||||
<p className="bgp-subtitle">
|
||||
The upstream service isn't responding. The proxy is confused.
|
||||
</p>
|
||||
<code className="bgp-kubectl">
|
||||
$ curl -I https://app.lab — <span className="err">502 Bad Gateway</span>
|
||||
</code>
|
||||
</header>
|
||||
|
||||
<section className="bgp-log-section" aria-label="Gateway diagnostics">
|
||||
<TerminalLog title="gateway-diagnostics — 502" />
|
||||
</section>
|
||||
|
||||
<footer className="bgp-footer">
|
||||
<a href="/" className="bgp-btn bgp-btn--primary">← Go Home</a>
|
||||
<a href="/maintenance" className="bgp-btn bgp-btn--secondary">
|
||||
Check maintenance status
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
78
src/pages/home/HomePage.css
Normal file
78
src/pages/home/HomePage.css
Normal file
@ -0,0 +1,78 @@
|
||||
/* Home / landing page */
|
||||
|
||||
.home-container {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-header { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.home-logo {
|
||||
font-size: 4rem;
|
||||
text-shadow: 0 0 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.home-title {
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
font-weight: 900;
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2px var(--accent);
|
||||
text-shadow: 0 0 40px var(--accent-glow);
|
||||
}
|
||||
|
||||
.home-subtitle { font-size: 1rem; color: var(--cyan-dim); }
|
||||
|
||||
.home-status {
|
||||
font-size: 0.78rem; color: var(--text-muted);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 0.3rem 0.8rem;
|
||||
}
|
||||
|
||||
/* Service grid */
|
||||
.home-services {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0.8rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.home-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 16px var(--accent-dim);
|
||||
}
|
||||
|
||||
.home-card-name { font-size: 0.9rem; font-weight: 600; }
|
||||
|
||||
.home-card-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.home-card-badge--running { background: rgba(0,255,136,0.12); color: #00ff88; }
|
||||
.home-card-badge--pending { background: rgba(68,153,255,0.12); color: #4499ff; }
|
||||
.home-card-badge--error { background: rgba(255,68,85,0.12); color: var(--danger); }
|
||||
|
||||
.home-footer { margin-top: 0.5rem; }
|
||||
.home-footer-text { font-size: 0.82rem; color: var(--text-muted); }
|
||||
.home-link { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.home-link:hover { color: var(--cyan); }
|
||||
43
src/pages/home/HomePage.jsx
Normal file
43
src/pages/home/HomePage.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
|
||||
import './HomePage.css'
|
||||
|
||||
/**
|
||||
* Home / landing page — placeholder.
|
||||
* TODO: add service list, status dashboard, or project links.
|
||||
*/
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="home-container">
|
||||
<header className="home-header">
|
||||
<div className="home-logo" aria-hidden="true">⎈</div>
|
||||
<h1 className="home-title">ErrorLab</h1>
|
||||
<p className="home-subtitle">Home Lab / DevOps Dashboard</p>
|
||||
<code className="home-status">cluster: online · nodes: healthy</code>
|
||||
</header>
|
||||
|
||||
{/* Placeholder service cards — replace with real data */}
|
||||
<section className="home-services" aria-label="Services">
|
||||
{[
|
||||
{ name: 'App', status: 'running', path: '#' },
|
||||
{ name: 'Monitoring', status: 'running', path: '#' },
|
||||
{ name: 'CI/CD', status: 'running', path: '#' },
|
||||
{ name: 'Storage', status: 'pending', path: '#' },
|
||||
].map(svc => (
|
||||
<a key={svc.name} href={svc.path} className={`home-card home-card--${svc.status}`}>
|
||||
<span className="home-card-name">{svc.name}</span>
|
||||
<span className={`home-card-badge home-card-badge--${svc.status}`}>{svc.status}</span>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<footer className="home-footer">
|
||||
<span className="home-footer-text">
|
||||
Something broken?
|
||||
<a href="/404" className="home-link">See the 404 game</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
74
src/pages/maintenance/MaintenancePage.css
Normal file
74
src/pages/maintenance/MaintenancePage.css
Normal file
@ -0,0 +1,74 @@
|
||||
/* Maintenance page */
|
||||
|
||||
.maint-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.maint-header { display: flex; flex-direction: column; align-items: center; gap: 0.7rem; }
|
||||
|
||||
.maint-icon { font-size: 3.5rem; }
|
||||
|
||||
.maint-title {
|
||||
font-size: clamp(1.5rem, 4vw, 2.2rem);
|
||||
font-weight: 800;
|
||||
color: var(--warning);
|
||||
text-shadow: 0 0 30px rgba(255,170,0,0.3);
|
||||
}
|
||||
|
||||
.maint-subtitle { font-size: 1rem; color: var(--text); min-height: 1.6em; }
|
||||
|
||||
.maint-status {
|
||||
font-size: 0.78rem; color: var(--text-muted);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 0.35rem 0.8rem;
|
||||
}
|
||||
.maint-status .info { color: var(--cyan); }
|
||||
|
||||
/* Infinite looping progress bar */
|
||||
.maint-progress {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 4px;
|
||||
background: var(--surface);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
.maint-progress-bar {
|
||||
height: 100%;
|
||||
width: 25%;
|
||||
background: linear-gradient(90deg, transparent, var(--warning), transparent);
|
||||
animation: indeterminate 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.maint-note { font-size: 0.82rem; color: var(--text-muted); }
|
||||
|
||||
.maint-footer { display: flex; justify-content: center; }
|
||||
|
||||
.maint-btn {
|
||||
padding: 0.55rem 1.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,170,0,0.35);
|
||||
background: rgba(255,170,0,0.1);
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.maint-btn:hover {
|
||||
background: rgba(255,170,0,0.18);
|
||||
border-color: var(--warning);
|
||||
box-shadow: 0 0 16px rgba(255,170,0,0.28);
|
||||
}
|
||||
51
src/pages/maintenance/MaintenancePage.jsx
Normal file
51
src/pages/maintenance/MaintenancePage.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
|
||||
import './MaintenancePage.css'
|
||||
|
||||
/**
|
||||
* Maintenance — placeholder.
|
||||
* TODO: add real duration, progress bar, or status feed.
|
||||
*/
|
||||
export default function MaintenancePage() {
|
||||
const [dots, setDots] = useState('.')
|
||||
|
||||
// Animate the "working on it" ellipsis
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setDots(d => d.length >= 3 ? '.' : d + '.')
|
||||
}, 600)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="maint-container">
|
||||
<header className="maint-header">
|
||||
<div className="maint-icon" aria-hidden="true">🔧</div>
|
||||
<h1 className="maint-title">Under Maintenance</h1>
|
||||
<p className="maint-subtitle">
|
||||
The lab is being upgraded{dots}
|
||||
</p>
|
||||
<code className="maint-status">
|
||||
$ kubectl rollout status deploy/lab — <span className="info">Waiting</span>
|
||||
</code>
|
||||
</header>
|
||||
|
||||
{/* Animated progress bar placeholder */}
|
||||
<div className="maint-progress" role="progressbar" aria-label="Maintenance in progress">
|
||||
<div className="maint-progress-bar" />
|
||||
</div>
|
||||
|
||||
<p className="maint-note">
|
||||
Services will be back shortly. Check back in a few minutes.
|
||||
</p>
|
||||
|
||||
<footer className="maint-footer">
|
||||
<button className="maint-btn" onClick={() => window.location.reload()}>
|
||||
↺ Refresh
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Each page can be built independently later by pointing `build.rollupOptions.input`
|
||||
// to its specific entry. For now, all pages share a single SPA dev server.
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user