html vers, deploy
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
*.err
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
README.md
|
||||
Deploy.ps1
|
||||
@@ -0,0 +1,4 @@
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
CONTACT_TO=alex@lean-101.com
|
||||
CONTACT_FROM=Lean 101 Website <noreply@lean-101.com>
|
||||
PORT=5000
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copy to .env.production and fill in.
|
||||
# .env.production is gitignored; Deploy.ps1 uploads it to the droplet.
|
||||
|
||||
# Resend API key — required for the contact form to actually send.
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Optional — both have sensible defaults in app.py if omitted.
|
||||
CONTACT_TO=alex@lean-101.com
|
||||
CONTACT_FROM=Lean 101 Website <noreply@lean-101.com>
|
||||
|
||||
# Host port the container binds to on the droplet (127.0.0.1:WEBSITE_APP_PORT -> 8000).
|
||||
# Host nginx vhost for www.lean-101.com.au should proxy_pass to this port.
|
||||
WEBSITE_APP_PORT=8083
|
||||
|
||||
# Droplet SSH password — used by Deploy.ps1 via sshpass so you don't have to type it.
|
||||
# Leave blank and pass -SshKey to Deploy.ps1 if you'd rather use key auth.
|
||||
SSH_PASSWORD=7Ajyef6UaAXHAnWrtUua
|
||||
+7
-4
@@ -1,11 +1,14 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
|
||||
.env
|
||||
.env.production
|
||||
.env.*.local
|
||||
|
||||
*.log
|
||||
*.err
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Lean 101 Website deployment script — ships the Flask site to the Digital Ocean droplet over SSH.
|
||||
|
||||
.DESCRIPTION
|
||||
Tars the local source tree, uploads it to the droplet, and runs
|
||||
docker compose up --build. No git required on the server.
|
||||
|
||||
Coexists with the clients app on the same droplet — different container name,
|
||||
different host port, different remote path. Host nginx routes by hostname.
|
||||
|
||||
.PARAMETER RemoteHost
|
||||
Hostname or IP of the Digital Ocean droplet. Required.
|
||||
|
||||
.PARAMETER RemoteUser
|
||||
SSH user. Defaults to 'root'.
|
||||
|
||||
.PARAMETER AppName
|
||||
Human-readable app name shown in the banner. Defaults to 'Website'.
|
||||
|
||||
.PARAMETER AppSlug
|
||||
Lowercase slug used in container/log labels. Defaults to 'website'.
|
||||
|
||||
.PARAMETER RemotePath
|
||||
Absolute path on the droplet. Defaults to '/srv/lean-101-website-flask'
|
||||
(deliberately distinct from the old /srv/lean101-website SvelteKit dir).
|
||||
|
||||
.PARAMETER ContainerName
|
||||
Backend container name to inspect. Defaults to 'lean101-website-flask'
|
||||
(must match container_name in docker-compose.yml).
|
||||
|
||||
.PARAMETER PortEnvKey
|
||||
Env var name in the env file that holds the published port.
|
||||
Defaults to 'WEBSITE_APP_PORT'.
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Local path to the production env file. Defaults to '.env.production'.
|
||||
|
||||
.PARAMETER SshKey
|
||||
Optional path to an SSH private key.
|
||||
|
||||
.PARAMETER ComposeFile
|
||||
Compose file name on the remote host. Defaults to 'docker-compose.yml'.
|
||||
|
||||
.PARAMETER Logs
|
||||
Tail logs for ~60 lines after deploy.
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Pass --no-build to docker compose (use when only env changed).
|
||||
|
||||
.PARAMETER NoBanner
|
||||
Suppress the ASCII banner.
|
||||
|
||||
.EXAMPLE
|
||||
./Deploy.ps1 -RemoteHost 209.38.24.231
|
||||
|
||||
.EXAMPLE
|
||||
./Deploy.ps1 -RemoteHost 209.38.24.231 -Logs
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
||||
[string] $RemoteUser = "root",
|
||||
[string] $AppName = "Website",
|
||||
[string] $AppSlug = "website",
|
||||
[string] $RemotePath = "/srv/lean-101-website-flask",
|
||||
[string] $ContainerName = "lean101-website-flask",
|
||||
[string] $PortEnvKey = "WEBSITE_APP_PORT",
|
||||
[string] $EnvFile = ".env.production",
|
||||
[string] $SshKey,
|
||||
[string] $ComposeFile = "docker-compose.yml",
|
||||
[switch] $Logs,
|
||||
[switch] $SkipBuild,
|
||||
[switch] $NoBanner
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Palette
|
||||
$Esc = [char]27
|
||||
$C = @{
|
||||
Reset = "$Esc[0m"; Dim = "$Esc[2m"; Bold = "$Esc[1m"
|
||||
Magenta = "$Esc[38;5;177m"; Pink = "$Esc[38;5;213m"
|
||||
Cyan = "$Esc[38;5;87m"; Teal = "$Esc[38;5;79m"
|
||||
Green = "$Esc[38;5;120m"; Yellow = "$Esc[38;5;221m"
|
||||
Red = "$Esc[38;5;203m"; Grey = "$Esc[38;5;244m"
|
||||
Blue = "$Esc[38;5;117m"
|
||||
}
|
||||
$Glyph = @{ Step='▸'; OK='✓'; Warn='!'; Info='·'; Fail='✗'; Arrow='→'; Spark='✦' }
|
||||
|
||||
function Write-Banner {
|
||||
if ($NoBanner) { return }
|
||||
$line = '─' * 62
|
||||
$title = "Lean 101 Website Deployment"
|
||||
$sub = "App: $AppName Slug: $AppSlug Target: $RemoteUser@$RemoteHost"
|
||||
Write-Host ""
|
||||
Write-Host ("$($C.Magenta)╭$line╮$($C.Reset)")
|
||||
Write-Host ("$($C.Magenta)│$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)│$($C.Reset)")
|
||||
Write-Host ("$($C.Magenta)│$($C.Reset) $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)│$($C.Reset)")
|
||||
Write-Host ("$($C.Magenta)╰$line╯$($C.Reset)")
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$script:StepIndex = 0
|
||||
function Write-Step([string]$msg) {
|
||||
$script:StepIndex++
|
||||
$num = "{0:D2}" -f $script:StepIndex
|
||||
Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)")
|
||||
}
|
||||
function Write-Ok([string]$msg) { Write-Host (" $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") }
|
||||
function Write-Warn([string]$msg) { Write-Host (" $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") }
|
||||
function Write-Info([string]$msg) { Write-Host (" $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") }
|
||||
function Write-Fail([string]$msg) { Write-Host (" $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") }
|
||||
|
||||
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
|
||||
|
||||
function Invoke-Spinner {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $Label,
|
||||
[Parameter(Mandatory = $true)] [string] $FilePath,
|
||||
[string[]] $ArgList = @(),
|
||||
[string] $StdinFile,
|
||||
[switch] $ShowOutput
|
||||
)
|
||||
$outFile = [System.IO.Path]::GetTempFileName()
|
||||
$errFile = [System.IO.Path]::GetTempFileName()
|
||||
$startParams = @{
|
||||
FilePath = $FilePath; NoNewWindow = $true; PassThru = $true
|
||||
RedirectStandardOutput = $outFile; RedirectStandardError = $errFile
|
||||
}
|
||||
if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList }
|
||||
if ($StdinFile) { $startParams.RedirectStandardInput = $StdinFile }
|
||||
|
||||
$proc = Start-Process @startParams
|
||||
$start = Get-Date
|
||||
$i = 0
|
||||
try {
|
||||
while (-not $proc.HasExited) {
|
||||
$elapsed = ((Get-Date) - $start).TotalSeconds
|
||||
$frame = $Spinner[$i % $Spinner.Count]
|
||||
Write-Host ("`r $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset) ") -NoNewline
|
||||
Start-Sleep -Milliseconds 90
|
||||
$i++
|
||||
}
|
||||
$proc.WaitForExit()
|
||||
$elapsed = ((Get-Date) - $start).TotalSeconds
|
||||
$stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' }
|
||||
$stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' }
|
||||
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
Write-Host ("`r $($C.Green)$($Glyph.OK)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12))
|
||||
if ($ShowOutput -and $stdout) {
|
||||
foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } }
|
||||
}
|
||||
return $stdout
|
||||
} else {
|
||||
Write-Host ("`r $($C.Red)$($Glyph.Fail)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8))
|
||||
if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host " $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } }
|
||||
if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host " $($C.Red)$ln$($C.Reset)" } } }
|
||||
throw "$Label failed (exit $($proc.ExitCode))"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $errFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepoRoot {
|
||||
if ($PSScriptRoot) { return $PSScriptRoot }
|
||||
return (Get-Location).Path
|
||||
}
|
||||
|
||||
function Get-EnvValue([string] $path, [string] $key) {
|
||||
foreach ($line in Get-Content $path) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith("#")) { continue }
|
||||
if ($trimmed -notmatch "=") { continue }
|
||||
$parts = $trimmed -split "=", 2
|
||||
if ($parts[0].Trim() -eq $key) { return $parts[1].Trim() }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$RepoRoot = Get-RepoRoot
|
||||
$SshTarget = "$RemoteUser@$RemoteHost"
|
||||
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no", "-i", $SshKey)
|
||||
|
||||
# Resolve env file path early so we can read SSH_PASSWORD before the main flow.
|
||||
$EnvPathEarly = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||
|
||||
function Resolve-PuttyTool([string] $name) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
foreach ($candidate in @(
|
||||
"$env:ProgramFiles\PuTTY\$name.exe",
|
||||
"${env:ProgramFiles(x86)}\PuTTY\$name.exe",
|
||||
"$env:LOCALAPPDATA\Programs\PuTTY\$name.exe"
|
||||
)) {
|
||||
if ($candidate -and (Test-Path $candidate)) { return $candidate }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$SshPassword = $null
|
||||
$PlinkPath = $null
|
||||
$PscpPath = $null
|
||||
if (-not $SshKey) {
|
||||
if (-not (Test-Path $EnvPathEarly)) {
|
||||
throw "Env file not found at '$EnvPathEarly'. Create it and add SSH_PASSWORD=<droplet root password> (or pass -SshKey to use key auth)."
|
||||
}
|
||||
$SshPassword = Get-EnvValue $EnvPathEarly 'SSH_PASSWORD'
|
||||
if (-not $SshPassword) {
|
||||
throw "SSH_PASSWORD is not set in '$EnvPathEarly'. Add a line `SSH_PASSWORD=<your droplet password>` (or pass -SshKey to use key auth instead)."
|
||||
}
|
||||
$PlinkPath = Resolve-PuttyTool 'plink'
|
||||
$PscpPath = Resolve-PuttyTool 'pscp'
|
||||
if (-not $PlinkPath -or -not $PscpPath) {
|
||||
throw "plink/pscp (PuTTY) are required for password auth on Windows but were not found. Install PuTTY: ``winget install --id PuTTY.PuTTY -e`` (or ``choco install putty -y``), then reopen PowerShell."
|
||||
}
|
||||
|
||||
# Pre-cache the SSH host key so subsequent -batch calls don't fail on first connect.
|
||||
# Use cmd's pipe — most reliable way to feed `y` to plink's host-key prompt on Windows.
|
||||
$cacheOut = & cmd /c "echo y | `"$PlinkPath`" -ssh -pw `"$SshPassword`" `"$SshTarget`" exit 2>&1"
|
||||
# Ignore exit code: the goal is just to write the host key into the PuTTY registry cache.
|
||||
$null = $cacheOut
|
||||
}
|
||||
|
||||
function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
|
||||
if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
|
||||
if ($SshPassword) {
|
||||
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, $cmd)
|
||||
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -ShowOutput:$ShowOutput
|
||||
} else {
|
||||
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, $cmd)) -ShowOutput:$ShowOutput
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
|
||||
if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
|
||||
if ($SshPassword) {
|
||||
$argList = @('-scp', '-pw', $SshPassword, '-batch', $local, "${SshTarget}:${remote}")
|
||||
Invoke-Spinner -Label $Label -FilePath $PscpPath -ArgList $argList
|
||||
} else {
|
||||
Invoke-Spinner -Label $Label -FilePath 'scp' -ArgList (@($SshOpts) + @($local, "${SshTarget}:${remote}"))
|
||||
}
|
||||
}
|
||||
|
||||
function Try-Ssh([string] $cmd) {
|
||||
if ($SshPassword) {
|
||||
& $PlinkPath -ssh -pw $SshPassword -batch $SshTarget $cmd
|
||||
} else {
|
||||
& ssh @SshOpts $SshTarget $cmd
|
||||
}
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function Invoke-SshScript([string] $script, [string] $Label) {
|
||||
if (-not $Label) { $Label = "ssh (remote script)" }
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
# Use LF line endings so bash on the remote doesn't choke on CR.
|
||||
[System.IO.File]::WriteAllText($tmp, ($script -replace "`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||
if ($SshPassword) {
|
||||
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, 'bash -s')
|
||||
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -StdinFile $tmp -ShowOutput
|
||||
} else {
|
||||
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
|
||||
}
|
||||
} finally {
|
||||
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Write-Banner
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||
if (-not (Test-Path $EnvPath)) {
|
||||
throw "Env file not found at '$EnvPath'. Copy .env.production.example to .env.production and fill in RESEND_API_KEY."
|
||||
}
|
||||
|
||||
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
|
||||
if (-not $AppPort) { $AppPort = "8083" }
|
||||
|
||||
Write-Step "Preflight"
|
||||
Write-Info "App : $AppName ($AppSlug)"
|
||||
Write-Info "Remote host : $SshTarget"
|
||||
Write-Info "Remote path : $RemotePath"
|
||||
Write-Info "Container : $ContainerName"
|
||||
Write-Info "Env file : $EnvPath"
|
||||
Write-Info "Compose file : $ComposeFile"
|
||||
Write-Info "Port ($PortEnvKey) : $AppPort"
|
||||
|
||||
Write-Step "Verifying SSH connectivity"
|
||||
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput
|
||||
|
||||
Write-Step "Checking that remote port $AppPort is free for $ContainerName"
|
||||
$portCheckCmd = @'
|
||||
set -e
|
||||
PORT='__APP_PORT__'
|
||||
SELF='__CONTAINER__'
|
||||
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$SELF" ]; then
|
||||
echo "Port $PORT is already owned by container: $OWNER" >&2
|
||||
exit 2
|
||||
fi
|
||||
'@.Replace('__APP_PORT__', $AppPort).Replace('__CONTAINER__', $ContainerName)
|
||||
try {
|
||||
Invoke-SshScript $portCheckCmd -Label "port $AppPort availability"
|
||||
} catch {
|
||||
throw "Remote port $AppPort is already in use by another container. Change $PortEnvKey in '$EnvPath' or retire the conflicting service first."
|
||||
}
|
||||
|
||||
Write-Step "Packaging source tree (excluding caches, secrets, logs)"
|
||||
$TarFile = Join-Path $env:TEMP "lean101-website-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
|
||||
$excludes = @(
|
||||
"--exclude=./.git",
|
||||
"--exclude=./.pytest_cache",
|
||||
"--exclude=./__pycache__",
|
||||
"--exclude=./**/__pycache__",
|
||||
"--exclude=./*.pyc",
|
||||
"--exclude=./.venv",
|
||||
"--exclude=./venv",
|
||||
"--exclude=./.env",
|
||||
"--exclude=./.env.production",
|
||||
"--exclude=./.env.*.local",
|
||||
"--exclude=./*.log",
|
||||
"--exclude=./*.err"
|
||||
)
|
||||
Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))
|
||||
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 2)
|
||||
Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
|
||||
|
||||
Write-Step "Ensuring remote path exists"
|
||||
Invoke-Ssh "mkdir -p '$RemotePath'" -Label "mkdir -p $RemotePath"
|
||||
|
||||
Write-Step "Uploading env file"
|
||||
Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
|
||||
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"
|
||||
|
||||
Write-Step "Uploading source archive ($TarSize MB)"
|
||||
Invoke-Scp $TarFile "/tmp/lean101-website-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
|
||||
Remove-Item $TarFile -Force
|
||||
|
||||
Write-Step "Extracting archive on server"
|
||||
Invoke-Ssh "tar -xzf /tmp/lean101-website-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-website-deploy.tar.gz" -Label "untar into $RemotePath"
|
||||
|
||||
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
|
||||
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
|
||||
|
||||
$buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
|
||||
Write-Step "Bringing the $AppName stack up $buildMsg"
|
||||
$composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
|
||||
try {
|
||||
Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag"
|
||||
} catch {
|
||||
Write-Warn "docker compose up failed; collecting status and logs"
|
||||
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" | Out-Null
|
||||
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120" | Out-Null
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Step "Waiting for container to be running ($ContainerName)"
|
||||
$runCheck = @"
|
||||
set -e
|
||||
for i in `$(seq 1 30); do
|
||||
status=`$(docker inspect --format='{{.State.Status}}' $ContainerName 2>/dev/null || echo missing)
|
||||
case "`$status" in
|
||||
running) echo "container is running"; exit 0 ;;
|
||||
*) printf '.'; sleep 2 ;;
|
||||
esac
|
||||
done
|
||||
echo; echo "container did not reach running state" >&2; exit 1
|
||||
"@
|
||||
Invoke-SshScript $runCheck -Label "container status (up to ~60s)"
|
||||
|
||||
Write-Step "HTTP readiness check on 127.0.0.1:$AppPort"
|
||||
$curlCmd = "for i in `$(seq 1 20); do code=`$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$AppPort/ 2>/dev/null || echo 000); if [ `"`$code`" = `"200`" ]; then echo `"HTTP `$code`"; exit 0; fi; sleep 2; done; echo `"never got 200`" >&2; exit 1"
|
||||
Invoke-Ssh $curlCmd -Label "curl http://127.0.0.1:$AppPort/" -ShowOutput
|
||||
|
||||
Write-Step "Stack status"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput
|
||||
|
||||
if ($Logs) {
|
||||
Write-Step "Recent logs (last 60 lines)"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
|
||||
}
|
||||
|
||||
Write-Step "Next steps for cutover"
|
||||
Write-Info "New container is healthy on $($C.Bold)127.0.0.1:$AppPort$($C.Reset)"
|
||||
Write-Info "Public domain still hits the OLD container until you swap the nginx vhost:"
|
||||
Write-Info " ssh $SshTarget"
|
||||
Write-Info " sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:$AppPort;|' /etc/nginx/sites-enabled/lean-101.com.au"
|
||||
Write-Info " nginx -t && systemctl reload nginx"
|
||||
Write-Info "Then verify: curl -I https://www.lean-101.com.au/"
|
||||
Write-Info "Once happy: cd /srv/lean101-website && docker compose down"
|
||||
|
||||
$line = '─' * 62
|
||||
Write-Host ""
|
||||
Write-Host ("$($C.Green)╭$line╮$($C.Reset)")
|
||||
$done = "$($Glyph.Spark) $AppName deployed to ${SshTarget}:$AppPort"
|
||||
$pad = [Math]::Max(0, 60 - ($done.Length - 2))
|
||||
Write-Host ("$($C.Green)│$($C.Reset) $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)│$($C.Reset)")
|
||||
Write-Host ("$($C.Green)╰$line╯$($C.Reset)")
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Fail "Deployment aborted: $($_.Exception.Message)"
|
||||
Write-Host ""
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"]
|
||||
@@ -1,165 +1,83 @@
|
||||
# Lean 101 Website
|
||||
|
||||
This repo runs as a server-rendered SvelteKit app with a custom Mongo-backed CMS at `/admin`.
|
||||
Plain static HTML site with a small Python (Flask) backend for the contact form.
|
||||
|
||||
## Stack
|
||||
## Layout
|
||||
|
||||
- `SvelteKit`
|
||||
- `@sveltejs/adapter-node`
|
||||
- `MongoDB` via the native `mongodb` driver
|
||||
- `docker compose` for local and server runtime
|
||||
|
||||
## What changed
|
||||
|
||||
- The public site now reads page content from MongoDB when `MONGODB_URI` and `MONGODB_DB` are set.
|
||||
- If MongoDB is not configured, the homepage falls back to the local seed file at `src/lib/content/homepage.json`.
|
||||
- `/admin` is a custom password-protected editor for:
|
||||
- pages
|
||||
- movable page sections
|
||||
- blog posts
|
||||
- The homepage seed is automatically migrated into the `site_pages` collection the first time the app connects to MongoDB.
|
||||
|
||||
## Project layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
├── scripts/
|
||||
│ └── deploy-do.sh
|
||||
├── src/
|
||||
│ ├── app.css
|
||||
│ ├── app.html
|
||||
│ ├── lib/
|
||||
│ │ ├── content/homepage.json
|
||||
│ │ ├── components/SitePage.svelte
|
||||
│ │ ├── server/auth.js
|
||||
│ │ ├── server/content.js
|
||||
│ │ └── site.js
|
||||
│ └── routes/
|
||||
│ ├── +layout.js
|
||||
│ ├── +page.server.js
|
||||
│ ├── +page.svelte
|
||||
│ ├── [slug]/+page.server.js
|
||||
│ ├── [slug]/+page.svelte
|
||||
│ ├── admin/+page.server.js
|
||||
│ ├── admin/+page.svelte
|
||||
│ ├── blog/+page.server.js
|
||||
│ ├── blog/+page.svelte
|
||||
│ ├── blog/[slug]/+page.server.js
|
||||
│ ├── blog/[slug]/+page.svelte
|
||||
│ ├── robots.txt/+server.js
|
||||
│ └── sitemap.xml/+server.js
|
||||
└── static/assets/
|
||||
├── lean101-isotipo.png
|
||||
└── lean101-logotipo.png
|
||||
```
|
||||
index.html # Home (includes contact form at #contact)
|
||||
services/ # coaching, consulting, digital-solutions pages
|
||||
resources/ # resources index
|
||||
assets/ # logos
|
||||
app.py # Flask app: serves the site + /api/contact (Resend)
|
||||
requirements.txt
|
||||
.env.example # copy to .env and fill in (local dev)
|
||||
Dockerfile # python:3.12-slim + gunicorn
|
||||
docker-compose.yml # single service: lean101, attaches to shared nginx network
|
||||
deploy.env.template # synced to server .env by deploy.sh (live values win)
|
||||
deploy.sh # remote deploy: git clone + compose up + nginx + maintenance
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Required for the CMS:
|
||||
|
||||
- `MONGODB_URI`
|
||||
- `MONGODB_DB`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `ADMIN_SESSION_SECRET`
|
||||
|
||||
Required for correct canonical/meta URLs:
|
||||
|
||||
- `PUBLIC_SITE_URL`
|
||||
|
||||
Example `.env`:
|
||||
|
||||
```env
|
||||
PUBLIC_SITE_URL=http://localhost:8080
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
MONGODB_DB=lean101
|
||||
ADMIN_PASSWORD=replace-this
|
||||
ADMIN_SESSION_SECRET=replace-this-with-a-long-random-string
|
||||
```
|
||||
|
||||
## Run locally with Docker
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
# source .venv/bin/activate # macOS/Linux
|
||||
pip install -r requirements.txt
|
||||
copy .env.example .env # then edit .env with your Resend key
|
||||
python app.py
|
||||
```
|
||||
|
||||
The site will be available at `http://localhost:8080`.
|
||||
Site at http://127.0.0.1:5000
|
||||
|
||||
The app server listens on port `3000` inside the container and is mapped to `8080` on the host.
|
||||
## Contact form
|
||||
|
||||
## Run locally without Docker
|
||||
- Form is on the home page in the `#contact` section.
|
||||
- Submits to `POST /api/contact` (JSON response).
|
||||
- Server sends the email via [Resend](https://resend.com). Configure:
|
||||
- `RESEND_API_KEY` — your Resend API key.
|
||||
- `CONTACT_TO` — where enquiries are sent.
|
||||
- `CONTACT_FROM` — must be on a domain you've verified in Resend.
|
||||
- Honeypot field (`website`) silently drops bots.
|
||||
|
||||
The "Let's Talk" buttons on service pages link back to the home `#contact` section, where the real form lives.
|
||||
|
||||
## Deployment
|
||||
|
||||
`Deploy.ps1` runs from your laptop. It tars the repo, scps it to the droplet, and runs `docker compose up --build` over SSH. Host nginx on the droplet terminates TLS for `www.lean-101.com.au` and proxies to `127.0.0.1:WEBSITE_APP_PORT` (default 8083) where this container binds.
|
||||
|
||||
First deploy:
|
||||
|
||||
```powershell
|
||||
copy .env.production.example .env.production # then edit and set RESEND_API_KEY
|
||||
./Deploy.ps1 -RemoteHost <droplet-ip>
|
||||
```
|
||||
|
||||
Subsequent deploys: same command. Add `-Logs` to tail logs after, `-SkipBuild` if only the env changed.
|
||||
|
||||
### Cutover (first deploy only)
|
||||
|
||||
The script deploys to `/srv/lean-101-website-flask` with container `lean101-website-flask` on port 8083, deliberately leaving the old SvelteKit container (`/srv/lean101-website`, port 8091) running. After verifying the new container is healthy, swap the nginx vhost on the droplet:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
ssh root@<droplet>
|
||||
sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:8083;|' \
|
||||
/etc/nginx/sites-enabled/lean-101.com.au
|
||||
nginx -t && systemctl reload nginx
|
||||
curl -I https://www.lean-101.com.au/ # should hit the new Flask container
|
||||
|
||||
# Once happy, retire the old stack:
|
||||
cd /srv/lean101-website && docker compose down
|
||||
```
|
||||
|
||||
The Vite dev server will run on its normal local port, usually `http://localhost:5173`.
|
||||
Rollback: `mv /etc/nginx/sites-enabled/lean-101.com.au.bak /etc/nginx/sites-enabled/lean-101.com.au && nginx -s reload`.
|
||||
|
||||
## Production build
|
||||
### Coexistence with the clients app
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`npm run preview` now runs the built Node server with `node build`.
|
||||
|
||||
## Custom CMS
|
||||
|
||||
Visit `/admin`.
|
||||
|
||||
The CMS is intentionally simple, but it now supports structured content:
|
||||
|
||||
- one password
|
||||
- multiple pages
|
||||
- reorderable sections within each page
|
||||
- blog drafts and published posts
|
||||
|
||||
Current collections:
|
||||
|
||||
- `site_pages`
|
||||
- `blog_posts`
|
||||
|
||||
Current section types:
|
||||
|
||||
- `hero`
|
||||
- `services`
|
||||
- `process`
|
||||
- `outcomes`
|
||||
- `why`
|
||||
- `richText`
|
||||
- `cta`
|
||||
|
||||
Published blog posts are available under `/blog`, and individual pages are available at `/<slug>`.
|
||||
|
||||
## DigitalOcean deployment script
|
||||
|
||||
After copying this project folder onto a droplet, you can run:
|
||||
|
||||
```bash
|
||||
sudo ./scripts/deploy-do.sh --domain yourdomain.com --email you@example.com --with-www
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- installs Docker, the compose plugin, nginx, and certbot
|
||||
- writes `.env` with `PUBLIC_SITE_URL=https://yourdomain.com`
|
||||
- starts the compose stack
|
||||
- configures system nginx to reverse proxy to the Docker app on `127.0.0.1:8080`
|
||||
- requests and installs a Let's Encrypt certificate if `--email` is provided
|
||||
|
||||
If you use the CMS in production, make sure the `.env` file on the server also includes:
|
||||
|
||||
- `MONGODB_URI`
|
||||
- `MONGODB_DB`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `ADMIN_SESSION_SECRET`
|
||||
The droplet runs host nginx → localhost ports → docker containers. The clients app at `clients.lean-101.com.au` proxies to `127.0.0.1:8082`, this site to `127.0.0.1:8083`. Each app has its own compose project and bridge network; they don't share anything.
|
||||
|
||||
## Notes
|
||||
|
||||
- The current contact CTA still uses `mailto:hello@lean-101.com`
|
||||
- The seed homepage remains in `src/lib/content/homepage.json`
|
||||
- Live page and blog content is stored in MongoDB once the database env vars are configured
|
||||
- The SvelteKit + Mongo stack was removed in favour of plain HTML so edits land directly. If you need anything from the prior stack, recover it from git history before this commit.
|
||||
- During the restructure the v3.11 service pages were lost (they were never committed) and restored from the older `html-v3` versions in HEAD. The v3.11 service-page tweaks (e.g. CTA copy rebranded to "Let's Talk") will need to be re-applied.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import resend
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SITE_ROOT = Path(__file__).parent.resolve()
|
||||
|
||||
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
|
||||
CONTACT_TO = os.environ.get("CONTACT_TO", "alex@lean-101.com")
|
||||
CONTACT_FROM = os.environ.get("CONTACT_FROM", "Lean 101 Website <noreply@lean-101.com>")
|
||||
|
||||
resend.api_key = RESEND_API_KEY
|
||||
|
||||
app = Flask(__name__, static_folder=None)
|
||||
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _clip(value: str, limit: int) -> str:
|
||||
value = (value or "").strip()
|
||||
return value[:limit]
|
||||
|
||||
|
||||
@app.post("/api/contact")
|
||||
def contact():
|
||||
form = request.form if request.form else request.get_json(silent=True) or {}
|
||||
|
||||
if (form.get("website") or "").strip():
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
name = _clip(form.get("name", ""), 200)
|
||||
email = _clip(form.get("email", ""), 200)
|
||||
company = _clip(form.get("company", ""), 200)
|
||||
message = _clip(form.get("message", ""), 5000)
|
||||
|
||||
if not name or not email or not message:
|
||||
return jsonify({"error": "Please fill in your name, email, and message."}), 400
|
||||
if not EMAIL_RE.match(email):
|
||||
return jsonify({"error": "That email address doesn't look right."}), 400
|
||||
|
||||
if not RESEND_API_KEY:
|
||||
app.logger.error("RESEND_API_KEY not configured")
|
||||
return jsonify({"error": "Email is not configured on the server."}), 500
|
||||
|
||||
html = (
|
||||
f"<p><strong>Name:</strong> {name}</p>"
|
||||
f"<p><strong>Email:</strong> {email}</p>"
|
||||
f"<p><strong>Company:</strong> {company or '—'}</p>"
|
||||
f"<p><strong>Looking to improve:</strong></p>"
|
||||
f"<p>{message.replace(chr(10), '<br>')}</p>"
|
||||
)
|
||||
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": CONTACT_FROM,
|
||||
"to": [CONTACT_TO],
|
||||
"reply_to": email,
|
||||
"subject": f"New Lean 101 enquiry — {name}"
|
||||
+ (f" ({company})" if company else ""),
|
||||
"html": html,
|
||||
})
|
||||
except Exception as exc:
|
||||
app.logger.exception("Resend send failed: %s", exc)
|
||||
return jsonify({"error": "Couldn't send your message. Please try again."}), 502
|
||||
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
def static_files(path: str):
|
||||
if not path:
|
||||
return send_from_directory(SITE_ROOT, "index.html")
|
||||
|
||||
target = (SITE_ROOT / path).resolve()
|
||||
try:
|
||||
target.relative_to(SITE_ROOT)
|
||||
except ValueError:
|
||||
return ("Not found", 404)
|
||||
|
||||
if target.is_dir():
|
||||
index = target / "index.html"
|
||||
if index.exists():
|
||||
return send_from_directory(target, "index.html")
|
||||
return ("Not found", 404)
|
||||
|
||||
if target.exists():
|
||||
return send_from_directory(target.parent, target.name)
|
||||
|
||||
html_candidate = target.with_suffix(".html")
|
||||
if html_candidate.exists():
|
||||
return send_from_directory(html_candidate.parent, html_candidate.name)
|
||||
|
||||
return ("Not found", 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "5000")), debug=True)
|
||||
@@ -1,500 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPO_URL=""
|
||||
BRANCH="main"
|
||||
REF=""
|
||||
DEPLOY_PATH=""
|
||||
COMPOSE_FILE=""
|
||||
PROJECT_NAME=""
|
||||
SERVICE_NAME=""
|
||||
NGINX_SOURCE=""
|
||||
NGINX_TARGET=""
|
||||
NGINX_COMPOSE_FILE=""
|
||||
NGINX_PROJECT_NAME=""
|
||||
MAINTENANCE_HOST_DIR=""
|
||||
MAINTENANCE_FLAG_PATH=""
|
||||
VERIFY_URL="https://www.goodwalk.co.nz/api/health"
|
||||
SKIP_SITE_CHECK=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
deploy-from-git.sh --repo-url <url> --branch <name> --deploy-path <path> --compose-file <name> --project-name <name>
|
||||
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
|
||||
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> \
|
||||
[--service <name>] [--nginx-source <path>] [--nginx-target <path>] \
|
||||
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
|
||||
[--maintenance-host-dir <path>] [--maintenance-flag <path>] \
|
||||
[--verify-url <url>] [--skip-site-check]
|
||||
|
||||
This script clones or fetches the application repo on the server, exports the
|
||||
homepage content payload, updates only the main Goodwalk compose project, and
|
||||
optionally updates the shared nginx stack plus maintenance mode handling.
|
||||
|
||||
Authentication for private HTTPS repos is expected to come from ~/.netrc,
|
||||
git-credential, or another Git-supported credential mechanism already present
|
||||
on the server.
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[deploy-git] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server"
|
||||
}
|
||||
|
||||
run_homepage_export() {
|
||||
local export_script="$1"
|
||||
local output_path="$2"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node --experimental-strip-types "$export_script" "$output_path"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container"
|
||||
docker run --rm \
|
||||
-v "$CHECKOUT_DIR:/app" \
|
||||
-w /app \
|
||||
node:22-alpine \
|
||||
node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo-url)
|
||||
REPO_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--branch)
|
||||
BRANCH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--ref)
|
||||
REF="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--deploy-path)
|
||||
DEPLOY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--compose-file)
|
||||
COMPOSE_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--project-name)
|
||||
PROJECT_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--service)
|
||||
SERVICE_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--nginx-source)
|
||||
NGINX_SOURCE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--nginx-target)
|
||||
NGINX_TARGET="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--nginx-compose-file)
|
||||
NGINX_COMPOSE_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--nginx-project-name)
|
||||
NGINX_PROJECT_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--maintenance-host-dir)
|
||||
MAINTENANCE_HOST_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--maintenance-flag)
|
||||
MAINTENANCE_FLAG_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--verify-url)
|
||||
VERIFY_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-site-check)
|
||||
SKIP_SITE_CHECK=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "Unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$REPO_URL" ]] || fail "--repo-url is required"
|
||||
[[ -n "$BRANCH" ]] || fail "--branch is required"
|
||||
[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required"
|
||||
[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required"
|
||||
[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required"
|
||||
if [[ -n "$SERVICE_NAME" ]]; then
|
||||
SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)"
|
||||
fi
|
||||
[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /"
|
||||
|
||||
nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME")
|
||||
nginx_args_present=0
|
||||
for value in "${nginx_args[@]}"; do
|
||||
if [[ -n "$value" ]]; then
|
||||
nginx_args_present=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if (( nginx_args_present )); then
|
||||
[[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled"
|
||||
[[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled"
|
||||
[[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled"
|
||||
[[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled"
|
||||
[[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled"
|
||||
[[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled"
|
||||
fi
|
||||
|
||||
assert_command git
|
||||
assert_command docker
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
else
|
||||
fail "Docker Compose is not installed on the server"
|
||||
fi
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")"
|
||||
CHECKOUT_DIR="$WORK_DIR/repo"
|
||||
PAYLOAD_DIR="$WORK_DIR/payload"
|
||||
MAINTENANCE_ACTIVE=0
|
||||
|
||||
clear_maintenance_flag() {
|
||||
if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then
|
||||
echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH"
|
||||
rm -f "$MAINTENANCE_FLAG_PATH" || true
|
||||
MAINTENANCE_ACTIVE=0
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
clear_maintenance_flag
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
|
||||
copy_checkout_to_payload() {
|
||||
mkdir -p "$PAYLOAD_DIR"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a \
|
||||
--exclude '.git' \
|
||||
--exclude '.env' \
|
||||
--exclude '.env.*' \
|
||||
"$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' item; do
|
||||
relative_path="${item#"$CHECKOUT_DIR"/}"
|
||||
|
||||
case "$relative_path" in
|
||||
.git|.git/*|.env|.env.*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
destination="$PAYLOAD_DIR/$relative_path"
|
||||
|
||||
if [[ -d "$item" ]]; then
|
||||
mkdir -p "$destination"
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
cp -f "$item" "$destination"
|
||||
done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0)
|
||||
}
|
||||
|
||||
copy_payload_to_deploy() {
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a \
|
||||
--exclude '.env' \
|
||||
--exclude '.env.*' \
|
||||
"$PAYLOAD_DIR"/ "$DEPLOY_PATH"/
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' item; do
|
||||
relative_path="${item#"$PAYLOAD_DIR"/}"
|
||||
|
||||
if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
destination="$DEPLOY_PATH/$relative_path"
|
||||
|
||||
if [[ -d "$item" ]]; then
|
||||
mkdir -p "$destination"
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
cp -f "$item" "$destination"
|
||||
done < <(find "$PAYLOAD_DIR" -mindepth 1 -print0)
|
||||
}
|
||||
|
||||
merge_env_file() {
|
||||
local template="$1"
|
||||
local live="$2"
|
||||
|
||||
[[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; }
|
||||
[[ -f "$live" ]] || { echo "[deploy-git] No live .env at $live, skipping merge"; return 0; }
|
||||
|
||||
local added diffs backup
|
||||
added="$(mktemp)"
|
||||
diffs="$(mktemp)"
|
||||
backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" '
|
||||
function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s }
|
||||
BEGIN {
|
||||
while ((getline line < live) > 0) {
|
||||
if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue
|
||||
eq = index(line, "=")
|
||||
if (eq == 0) continue
|
||||
k = trim(substr(line, 1, eq-1))
|
||||
v = substr(line, eq+1)
|
||||
live_keys[k] = v
|
||||
live_seen[k] = 1
|
||||
}
|
||||
close(live)
|
||||
}
|
||||
/^[ \t]*#/ || /^[ \t]*$/ { next }
|
||||
{
|
||||
eq = index($0, "=")
|
||||
if (eq == 0) next
|
||||
k = trim(substr($0, 1, eq-1))
|
||||
v = substr($0, eq+1)
|
||||
if (!(k in live_seen)) {
|
||||
print k "=" v >> added_log
|
||||
} else if (live_keys[k] != v) {
|
||||
print k " (template=" v " | live=" live_keys[k] ")" >> diff_log
|
||||
}
|
||||
}
|
||||
' "$template"
|
||||
|
||||
if [[ -s "$added" ]]; then
|
||||
cp "$live" "$backup"
|
||||
echo "[deploy-git] Adding env keys present in template but missing from $live:"
|
||||
sed 's/^/ + /' "$added"
|
||||
{
|
||||
printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
cat "$added"
|
||||
} >> "$live"
|
||||
echo "[deploy-git] Backup of previous .env written to $backup"
|
||||
else
|
||||
echo "[deploy-git] .env is up to date with template (no missing keys)"
|
||||
fi
|
||||
|
||||
if [[ -s "$diffs" ]]; then
|
||||
echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:"
|
||||
sed 's/^/ ! /' "$diffs"
|
||||
echo "[deploy-git] If a live value is stale, edit $live and re-deploy."
|
||||
fi
|
||||
|
||||
rm -f "$added" "$diffs"
|
||||
}
|
||||
|
||||
check_site() {
|
||||
if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[deploy-git] Checking production site: $VERIFY_URL"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
local http_code
|
||||
if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then
|
||||
echo "[deploy-git] Site responded with HTTP $http_code"
|
||||
else
|
||||
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then
|
||||
awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$
|
||||
else
|
||||
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
|
||||
fi
|
||||
rm -f /tmp/goodwalk-site-check.$$
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[deploy-git] Deploying main Goodwalk stack from Git"
|
||||
echo "[deploy-git] Repo URL: $REPO_URL"
|
||||
echo "[deploy-git] Branch: $BRANCH"
|
||||
if [[ -n "$REF" ]]; then
|
||||
echo "[deploy-git] Requested ref: $REF"
|
||||
fi
|
||||
echo "[deploy-git] Target deployment path: $DEPLOY_PATH"
|
||||
echo "[deploy-git] Compose file: $COMPOSE_FILE"
|
||||
echo "[deploy-git] Docker project: $PROJECT_NAME"
|
||||
if [[ -n "$SERVICE_NAME" ]]; then
|
||||
echo "[deploy-git] Target service: $SERVICE_NAME"
|
||||
fi
|
||||
if (( nginx_args_present )); then
|
||||
echo "[deploy-git] Nginx config source: $NGINX_SOURCE"
|
||||
echo "[deploy-git] Nginx config target: $NGINX_TARGET"
|
||||
echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE"
|
||||
echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME"
|
||||
echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR"
|
||||
echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH"
|
||||
fi
|
||||
|
||||
echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR"
|
||||
git clone "$REPO_URL" "$CHECKOUT_DIR"
|
||||
git -C "$CHECKOUT_DIR" fetch --tags --prune origin
|
||||
|
||||
if [[ -n "$REF" ]]; then
|
||||
git -C "$CHECKOUT_DIR" checkout --detach "$REF"
|
||||
else
|
||||
git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
|
||||
fi
|
||||
|
||||
DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)"
|
||||
echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION"
|
||||
|
||||
EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs"
|
||||
[[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT"
|
||||
echo "[deploy-git] Exporting current homepage content for PostgreSQL sync"
|
||||
run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json"
|
||||
|
||||
echo "[deploy-git] Preparing deployment payload"
|
||||
copy_checkout_to_payload
|
||||
|
||||
[[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE"
|
||||
|
||||
if [[ -f "$DEPLOY_PATH/.env" ]]; then
|
||||
echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env"
|
||||
fi
|
||||
|
||||
echo "[deploy-git] Copying application files into $DEPLOY_PATH"
|
||||
copy_payload_to_deploy
|
||||
|
||||
[[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE"
|
||||
|
||||
if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
|
||||
if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then
|
||||
echo "[deploy-git] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template"
|
||||
cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
|
||||
else
|
||||
fail "Remote .env is missing and deploy.env.template was not present"
|
||||
fi
|
||||
fi
|
||||
|
||||
merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
echo "[deploy-git] Validating compose configuration"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null
|
||||
|
||||
if [[ -n "$SERVICE_NAME" ]]; then
|
||||
AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)"
|
||||
if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then
|
||||
fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( nginx_args_present )); then
|
||||
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
|
||||
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
|
||||
|
||||
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
|
||||
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
|
||||
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
|
||||
[[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC"
|
||||
|
||||
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
|
||||
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
|
||||
|
||||
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
|
||||
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
|
||||
fail "nginx container is missing the maintenance bind mount.
|
||||
Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
|
||||
One-time setup on the droplet:
|
||||
mkdir -p ${MAINTENANCE_HOST_DIR}/m
|
||||
# add this volume to ${NGINX_COMPOSE_FILE}:
|
||||
# - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
|
||||
${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d"
|
||||
fi
|
||||
|
||||
FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")"
|
||||
[[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR"
|
||||
|
||||
echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR"
|
||||
mkdir -p "$MAINTENANCE_HOST_DIR/m"
|
||||
install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html"
|
||||
install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png"
|
||||
|
||||
echo "[deploy-git] Updating shared nginx config (pre-rebuild) so maintenance routing is active"
|
||||
mkdir -p "$(dirname "$NGINX_TARGET")"
|
||||
cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET"
|
||||
|
||||
echo "[deploy-git] Validating nginx configuration"
|
||||
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
|
||||
|
||||
echo "[deploy-git] Reloading shared nginx so the new config (incl. maintenance routing) is live"
|
||||
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
|
||||
|
||||
echo "[deploy-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH"
|
||||
: > "$MAINTENANCE_FLAG_PATH"
|
||||
MAINTENANCE_ACTIVE=1
|
||||
fi
|
||||
|
||||
if [[ -n "$SERVICE_NAME" ]]; then
|
||||
echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
|
||||
|
||||
echo "[deploy-git] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME"
|
||||
else
|
||||
echo "[deploy-git] Stopping only the Goodwalk project containers"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
|
||||
|
||||
echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||
fi
|
||||
|
||||
echo "[deploy-git] Current Goodwalk container status"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
|
||||
|
||||
if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then
|
||||
echo "[deploy-git] Syncing homepage content into PostgreSQL"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
|
||||
fi
|
||||
|
||||
clear_maintenance_flag
|
||||
check_site
|
||||
|
||||
echo "[deploy-git] Remote deployment finished"
|
||||
echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION"
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
lean101:
|
||||
build: .
|
||||
image: lean-101-website:latest
|
||||
container_name: lean101-website-flask
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
ports:
|
||||
- "127.0.0.1:${WEBSITE_APP_PORT:-8083}:8000"
|
||||
+2267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
Flask>=3.0
|
||||
resend>=2.0
|
||||
python-dotenv>=1.0
|
||||
@@ -0,0 +1,952 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Resources — Lean 101</title>
|
||||
<meta name="description" content="Tools, training, book summaries and posts from Lean 101 — the methods, frameworks and reading that inform the work we do.">
|
||||
<link rel="preconnect" href="https://api.fontshare.com">
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--blue: #0070c7;
|
||||
--blue-deep: #005aa3;
|
||||
--orange: #ec8b33;
|
||||
--charcoal: #565656;
|
||||
--grey: #efefef;
|
||||
--grey-soft: #f6f7f9;
|
||||
--ink: #1a1a1a;
|
||||
--white: #ffffff;
|
||||
--accent: var(--blue);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'General Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--white);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
img { max-width: 100%; display: block; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* Anchor offset so the sticky nav doesn't cover section headings */
|
||||
section[id] { scroll-margin-top: 88px; }
|
||||
|
||||
/* ───────── NAV ───────── */
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 28px;
|
||||
height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-logo img {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-links > li { position: relative; }
|
||||
.nav-links > li > a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.nav-links > li > a:hover { opacity: 1; }
|
||||
|
||||
/* Active-page indicator — small accent underline below current page link */
|
||||
.nav-links > li > a.current {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
}
|
||||
.nav-links > li > a.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -4px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent, var(--blue));
|
||||
}
|
||||
|
||||
/* Chevron button — separate from the link, click-only behaviour */
|
||||
.nav-chevron {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 6px 4px;
|
||||
margin: 0 0 0 2px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
opacity: 0.6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s, transform 0.2s ease;
|
||||
}
|
||||
.nav-chevron:hover { opacity: 1; }
|
||||
.nav-chevron svg { display: block; width: 11px; height: 11px; }
|
||||
.nav-chevron[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ───── Mega-panel ───── */
|
||||
.nav-panel-wrap {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding-top: 14px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s;
|
||||
z-index: 200;
|
||||
}
|
||||
.nav-panel-wrap.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.nav-panel {
|
||||
background: var(--white);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.20),
|
||||
0 6px 16px -6px rgba(0, 0, 0, 0.08);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
width: max-content;
|
||||
max-width: min(960px, calc(100vw - 56px));
|
||||
transform: translateY(-6px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.nav-panel-wrap.is-open .nav-panel { transform: translateY(0); }
|
||||
.nav-panel.is-services {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
min-width: 640px;
|
||||
}
|
||||
.nav-panel.is-resources {
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.nav-panel-col { display: flex; flex-direction: column; gap: 6px; }
|
||||
.nav-panel-col > a,
|
||||
.nav-panel-col-head {
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-panel-col-head {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--ink);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
a.nav-panel-col-head:hover { color: var(--blue); }
|
||||
.nav-panel-col-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: rgba(86, 86, 86, 0.85);
|
||||
margin: 0;
|
||||
}
|
||||
.nav-panel-col-link {
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--blue);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
transition: gap 0.15s ease;
|
||||
}
|
||||
.nav-panel-col-link:hover { gap: 8px; }
|
||||
.nav-panel-col-tag {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(86, 86, 86, 0.65);
|
||||
}
|
||||
|
||||
|
||||
.nav-cta {
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
padding: 9px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.nav-cta:hover {
|
||||
background: var(--blue-deep);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─── Hamburger button (mobile only) ─── */
|
||||
.nav-hamburger {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.nav-hamburger:hover {
|
||||
background: var(--grey-soft);
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.nav-hamburger svg { display: block; width: 22px; height: 22px; }
|
||||
|
||||
/* ─── Mobile menu (full-screen takeover) ─── */
|
||||
.nav-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 99;
|
||||
}
|
||||
.nav-drawer-overlay.is-open { pointer-events: auto; }
|
||||
|
||||
.nav-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--white);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-drawer.is-open { transform: translateY(0); }
|
||||
|
||||
.nav-drawer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nav-drawer-head img { height: 30px; width: auto; }
|
||||
.nav-drawer-close {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.nav-drawer-close:hover {
|
||||
background: var(--grey-soft);
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.nav-drawer-close svg { display: block; width: 20px; height: 20px; }
|
||||
|
||||
.nav-drawer-body {
|
||||
padding: 36px 28px 28px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.nav-drawer ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.nav-drawer-item {
|
||||
display: block;
|
||||
padding: 18px 0;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
transition: color 0.15s, padding-left 0.2s ease;
|
||||
}
|
||||
.nav-drawer-item:hover { color: var(--blue); padding-left: 6px; }
|
||||
|
||||
.nav-drawer-group-label {
|
||||
display: block;
|
||||
padding: 28px 0 12px;
|
||||
color: var(--charcoal);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nav-drawer-subitem {
|
||||
display: block;
|
||||
padding: 16px 0 16px 14px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-left: 3px solid transparent;
|
||||
transition: color 0.15s, border-left-color 0.2s ease, padding-left 0.2s ease;
|
||||
}
|
||||
.nav-drawer-subitem:hover {
|
||||
color: var(--blue);
|
||||
border-left-color: var(--blue);
|
||||
padding-left: 20px;
|
||||
}
|
||||
.nav-drawer-subitem.coaching:hover {
|
||||
color: var(--orange);
|
||||
border-left-color: var(--orange);
|
||||
}
|
||||
|
||||
.nav-drawer-foot {
|
||||
padding: 24px 28px 32px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nav-drawer-cta {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background: var(--blue);
|
||||
color: var(--white);
|
||||
padding: 16px 22px;
|
||||
border-radius: 999px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.nav-drawer-cta:hover { background: var(--blue-deep); }
|
||||
|
||||
.nav-drawer-subitem.current {
|
||||
color: var(--blue);
|
||||
border-left-color: var(--blue);
|
||||
padding-left: 20px;
|
||||
}
|
||||
.nav-drawer-subitem.coaching.current {
|
||||
color: var(--orange);
|
||||
border-left-color: var(--orange);
|
||||
}
|
||||
|
||||
body.nav-drawer-locked { overflow: hidden; }
|
||||
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-cta { display: none; }
|
||||
.nav-hamburger { display: inline-flex; align-items: center; justify-content: center; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ─── PAGE HERO ─── */
|
||||
.page-hero {
|
||||
padding: 110px 28px 90px;
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%);
|
||||
}
|
||||
.page-hero-inner { max-width: 900px; margin: 0 auto; text-align: center; }
|
||||
.page-eyebrow {
|
||||
display: inline-block; color: var(--accent); font-size: 13px;
|
||||
font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: clamp(36px, 5.5vw, 64px); font-weight: 600;
|
||||
letter-spacing: -0.03em; line-height: 1.05; margin-bottom: 22px;
|
||||
}
|
||||
.page-hero h1 em {
|
||||
font-style: normal; color: var(--accent);
|
||||
}
|
||||
.page-hero-sub {
|
||||
font-size: clamp(17px, 1.6vw, 20px); color: var(--charcoal);
|
||||
max-width: 720px; margin: 0 auto; line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ─── SECTIONS ─── */
|
||||
.section { padding: 110px 28px; }
|
||||
.section.alt { background: var(--grey-soft); }
|
||||
.section-inner { max-width: 1100px; margin: 0 auto; }
|
||||
.section-head { max-width: 720px; margin-bottom: 48px; }
|
||||
.section-eyebrow {
|
||||
color: var(--blue); font-size: 14px; font-weight: 600;
|
||||
letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 14px;
|
||||
display: block;
|
||||
}
|
||||
.section-title {
|
||||
font-size: clamp(30px, 3.5vw, 42px); font-weight: 600;
|
||||
letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 16px;
|
||||
}
|
||||
.section-lede {
|
||||
font-size: 17px; color: var(--charcoal); line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ─── Resource cards ─── */
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.resource-card {
|
||||
background: var(--white);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
.resource-card[href]:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 12px 28px -12px rgba(0,0,0,0.12);
|
||||
}
|
||||
.resource-card-source {
|
||||
align-self: flex-start;
|
||||
font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: rgba(86, 86, 86, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--grey-soft);
|
||||
}
|
||||
.resource-card h3 {
|
||||
font-size: 19px; font-weight: 600;
|
||||
letter-spacing: -0.01em; line-height: 1.3;
|
||||
}
|
||||
.resource-card p {
|
||||
font-size: 14.5px; color: var(--charcoal); line-height: 1.55;
|
||||
}
|
||||
.resource-card-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--blue);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
transition: gap 0.15s ease;
|
||||
}
|
||||
.resource-card[href]:hover .resource-card-cta { gap: 8px; }
|
||||
|
||||
/* Empty-state cards have a softer look */
|
||||
.resource-card.is-empty {
|
||||
background: var(--grey-soft);
|
||||
border-style: dashed;
|
||||
border-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
.resource-card.is-empty:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.resource-card.is-empty h3 {
|
||||
color: var(--charcoal);
|
||||
}
|
||||
|
||||
/* ─── CTA section ─── */
|
||||
.cta-wrap {
|
||||
padding: 100px 28px;
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
}
|
||||
.cta-card {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
}
|
||||
.cta-card h2 {
|
||||
font-size: clamp(30px, 3.5vw, 44px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.cta-card p {
|
||||
font-size: 17px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta-card .btn-primary {
|
||||
background: var(--blue);
|
||||
color: var(--white);
|
||||
}
|
||||
.cta-card .btn-primary:hover { background: var(--blue-deep); }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 14px 26px; border-radius: 999px; font-size: 15px; font-weight: 500;
|
||||
text-decoration: none; transition: all 0.2s; border: none; cursor: pointer;
|
||||
}
|
||||
.btn-arrow { transition: transform 0.2s; }
|
||||
.btn:hover .btn-arrow { transform: translateX(3px); }
|
||||
|
||||
/* ─── FOOTER ─── */
|
||||
.footer {
|
||||
background: #0a0a0a;
|
||||
color: rgba(255,255,255,0.7);
|
||||
padding: 80px 28px 40px;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.footer-brand img {
|
||||
max-height: 56px;
|
||||
width: auto;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.footer-brand p {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.footer-col h5 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.footer-col ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.footer-col a {
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-col a:hover { color: var(--orange); }
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.section { padding: 80px 22px; }
|
||||
.page-hero { padding: 80px 22px 60px; }
|
||||
.cta-wrap { padding: 70px 22px; }
|
||||
.cta-card { padding: 40px 24px; }
|
||||
section[id] { scroll-margin-top: 80px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ───── NAV ───── -->
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<div class="nav-inner">
|
||||
<a href="../index.html" class="nav-logo" aria-label="Lean 101 home">
|
||||
<img src="../assets/lean101-isotipo.png" alt="Lean 101">
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="../index.html">Home</a></li>
|
||||
|
||||
<li class="has-panel">
|
||||
<a href="../index.html#services">Services</a>
|
||||
<button class="nav-chevron" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="nav-panel-services" aria-label="Open Services menu" data-panel-trigger="services">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 4.5 6 8 9 4.5"/></svg>
|
||||
</button>
|
||||
<div class="nav-panel-wrap" id="nav-panel-services" data-panel="services" role="region" aria-label="Services menu">
|
||||
<div class="nav-panel is-services">
|
||||
<div class="nav-panel-col">
|
||||
<a href="../services/consulting.html" class="nav-panel-col-head">Process Excellence</a>
|
||||
<p class="nav-panel-col-desc">Diagnosing where work breaks down, designing the fix, and embedding the change with you.</p>
|
||||
<a href="../services/consulting.html" class="nav-panel-col-link">Learn more <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<div class="nav-panel-col">
|
||||
<a href="../services/coaching.html" class="nav-panel-col-head">Coaching & Training</a>
|
||||
<p class="nav-panel-col-desc">Building the capability for your team to lead improvement themselves.</p>
|
||||
<a href="../services/coaching.html" class="nav-panel-col-link">Learn more <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<div class="nav-panel-col">
|
||||
<a href="../services/digital-solutions.html" class="nav-panel-col-head">Digital Solutions</a>
|
||||
<p class="nav-panel-col-desc">Custom dashboards, web apps and automation, built for your specific work.</p>
|
||||
<a href="../services/digital-solutions.html" class="nav-panel-col-link">Learn more <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="has-panel">
|
||||
<a href="index.html" class="current">Resources</a>
|
||||
<button class="nav-chevron" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="nav-panel-resources" aria-label="Open Resources menu" data-panel-trigger="resources">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 4.5 6 8 9 4.5"/></svg>
|
||||
</button>
|
||||
<div class="nav-panel-wrap" id="nav-panel-resources" data-panel="resources" role="region" aria-label="Resources menu">
|
||||
<div class="nav-panel is-resources">
|
||||
<div class="nav-panel-col">
|
||||
<a href="#principles" class="nav-panel-col-head">Principles</a>
|
||||
<p class="nav-panel-col-desc">The why of Lean — the philosophy underneath the methods.</p>
|
||||
<span class="nav-panel-col-tag">Coming soon</span>
|
||||
</div>
|
||||
<div class="nav-panel-col">
|
||||
<a href="#techniques" class="nav-panel-col-head">Techniques</a>
|
||||
<p class="nav-panel-col-desc">The how of Lean — specific methods explained in plain terms.</p>
|
||||
<span class="nav-panel-col-tag">Coming soon</span>
|
||||
</div>
|
||||
<div class="nav-panel-col">
|
||||
<a href="#reading" class="nav-panel-col-head">Reading</a>
|
||||
<p class="nav-panel-col-desc">Summaries of the books and articles that shaped how we think.</p>
|
||||
<span class="nav-panel-col-tag">Coming soon</span>
|
||||
</div>
|
||||
<div class="nav-panel-col">
|
||||
<a href="#templates" class="nav-panel-col-head">Templates</a>
|
||||
<p class="nav-panel-col-desc">Worksheets, checklists and audits you can download and use.</p>
|
||||
<span class="nav-panel-col-tag">Coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li><a href="../index.html#contact">Contact</a></li>
|
||||
</ul>
|
||||
<a href="../index.html#contact" class="nav-cta">Let’s Talk</a>
|
||||
<button class="nav-hamburger" type="button" aria-label="Open menu" aria-expanded="false" aria-controls="nav-drawer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- Mobile drawer + overlay -->
|
||||
<div class="nav-drawer-overlay" data-drawer-overlay aria-hidden="true"></div>
|
||||
<aside class="nav-drawer" id="nav-drawer" aria-label="Mobile navigation" aria-hidden="true">
|
||||
<div class="nav-drawer-head">
|
||||
<img src="../assets/lean101-logotipo.png" alt="Lean 101">
|
||||
<button class="nav-drawer-close" type="button" aria-label="Close menu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="nav-drawer-body">
|
||||
<ul>
|
||||
<li><a class="nav-drawer-item" href="../index.html">Home</a></li>
|
||||
<li><span class="nav-drawer-group-label">Services</span></li>
|
||||
<li><a class="nav-drawer-subitem" href="../services/consulting.html">Process Excellence</a></li>
|
||||
<li><a class="nav-drawer-subitem coaching" href="../services/coaching.html">Coaching & Training</a></li>
|
||||
<li><a class="nav-drawer-subitem" href="../services/digital-solutions.html">Digital Solutions</a></li>
|
||||
<li><span class="nav-drawer-group-label">Resources</span></li>
|
||||
<li><a class="nav-drawer-subitem" href="#principles">Principles</a></li>
|
||||
<li><a class="nav-drawer-subitem" href="#techniques">Techniques</a></li>
|
||||
<li><a class="nav-drawer-subitem" href="#reading">Reading</a></li>
|
||||
<li><a class="nav-drawer-subitem" href="#templates">Templates</a></li>
|
||||
<li><a class="nav-drawer-item" href="../index.html#contact">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="nav-drawer-foot">
|
||||
<a href="../index.html#contact" class="nav-drawer-cta">Let’s Talk</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ───── PAGE HERO ───── -->
|
||||
<header class="page-hero">
|
||||
<div class="page-hero-inner">
|
||||
<span class="page-eyebrow">Resources</span>
|
||||
<h1>Tools, methods and the reading <em>behind the work.</em></h1>
|
||||
<p class="page-hero-sub">
|
||||
A growing library of templates, training, book summaries and short posts —
|
||||
the same material we draw on with clients, made available so you can use it on your own.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ───── PRINCIPLES ───── -->
|
||||
<section class="section" id="principles">
|
||||
<div class="section-inner">
|
||||
<div class="section-head">
|
||||
<span class="section-eyebrow">Principles</span>
|
||||
<h2 class="section-title">The <em>why</em> of Lean.</h2>
|
||||
<p class="section-lede">
|
||||
The philosophy underneath the techniques — flow, pull, respect for
|
||||
people, capability over dependency, kaizen culture. The ideas that
|
||||
explain why the methods work, and what to hold onto when the methods
|
||||
don’t fit the situation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="resource-grid">
|
||||
<div class="resource-card is-empty">
|
||||
<span class="resource-card-source">Coming soon</span>
|
||||
<h3>Principle explainers are in production.</h3>
|
||||
<p>Long-form pages on the underlying ideas of Lean — what they are, where they came from, and how they show up in real improvement work.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── TECHNIQUES ───── -->
|
||||
<section class="section alt" id="techniques">
|
||||
<div class="section-inner">
|
||||
<div class="section-head">
|
||||
<span class="section-eyebrow">Techniques</span>
|
||||
<h2 class="section-title">The <em>how</em> of Lean.</h2>
|
||||
<p class="section-lede">
|
||||
Plain-English explanations of specific methods we use — 5S, Kanban,
|
||||
Value Stream Mapping, A3, PDCA, Poka-Yoke, Standard Work, Gemba walks.
|
||||
One page per technique, with what it is, how to run it, and where it
|
||||
tends to go wrong.
|
||||
</p>
|
||||
</div>
|
||||
<div class="resource-grid">
|
||||
<div class="resource-card is-empty">
|
||||
<span class="resource-card-source">Coming soon</span>
|
||||
<h3>Technique pages in production.</h3>
|
||||
<p>Each page covers one technique end-to-end — the mechanics, the typical pitfalls, and a worked example. The first pages will land here as they’re finished.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── READING ───── -->
|
||||
<section class="section" id="reading">
|
||||
<div class="section-inner">
|
||||
<div class="section-head">
|
||||
<span class="section-eyebrow">Reading</span>
|
||||
<h2 class="section-title">Books and articles behind the work.</h2>
|
||||
<p class="section-lede">
|
||||
Distilled summaries of the books and articles that shaped how we think
|
||||
about improvement — what’s still useful, what hasn’t aged
|
||||
well, and where the ideas show up in the work we do today.
|
||||
</p>
|
||||
</div>
|
||||
<div class="resource-grid">
|
||||
<div class="resource-card is-empty">
|
||||
<span class="resource-card-source">Coming soon</span>
|
||||
<h3>First summaries on the way.</h3>
|
||||
<p>Starting with the books that come up most in our work — The Toyota Way, The Goal, Learning to See, Out of the Crisis. Each summary will link out to the source.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── TEMPLATES ───── -->
|
||||
<section class="section alt" id="templates">
|
||||
<div class="section-inner">
|
||||
<div class="section-head">
|
||||
<span class="section-eyebrow">Templates</span>
|
||||
<h2 class="section-title">Worksheets, checklists and audits.</h2>
|
||||
<p class="section-lede">
|
||||
Practical artefacts you can download and use on your own work — 5S audit
|
||||
checklists, waste walk worksheets, CI readiness self-assessments, and
|
||||
more as we publish them.
|
||||
</p>
|
||||
</div>
|
||||
<div class="resource-grid">
|
||||
<div class="resource-card is-empty">
|
||||
<span class="resource-card-source">Coming soon</span>
|
||||
<h3>The first templates are on their way.</h3>
|
||||
<p>We’re preparing the first set of templates for download. Follow Lean 101 on LinkedIn to be notified when they go live.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── CTA ───── -->
|
||||
<section class="cta-wrap">
|
||||
<div class="cta-card">
|
||||
<h2>Have a problem worth solving?</h2>
|
||||
<p>Tell us a little about your business and what you're looking to improve. We'll get back to you to talk through whether — and how — we can help.</p>
|
||||
<a href="../index.html#contact" class="btn btn-primary">
|
||||
Let’s Talk <span class="btn-arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── FOOTER ───── -->
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<img src="../assets/lean101-logotipo.png" alt="Lean 101">
|
||||
<p>For businesses that want change to last.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Services</h5>
|
||||
<ul>
|
||||
<li><a href="../services/consulting.html">Process Excellence</a></li>
|
||||
<li><a href="../services/coaching.html">Coaching & Training</a></li>
|
||||
<li><a href="../services/digital-solutions.html">Digital Solutions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Resources</h5>
|
||||
<ul>
|
||||
<li><a href="#principles">Principles</a></li>
|
||||
<li><a href="#techniques">Techniques</a></li>
|
||||
<li><a href="#reading">Reading</a></li>
|
||||
<li><a href="#templates">Templates</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Company</h5>
|
||||
<ul>
|
||||
<li><a href="../index.html#why">About</a></li>
|
||||
<li><a href="../index.html#process">Approach</a></li>
|
||||
<li><a href="../index.html#contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Connect</h5>
|
||||
<ul>
|
||||
<li><a href="#">LinkedIn</a></li>
|
||||
<li><a href="mailto:hello@lean-101.com">Email</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Lean 101. All rights reserved.</span>
|
||||
<span>Based in Melbourne, working worldwide.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// ─── Mobile drawer (hamburger menu) ───
|
||||
const drawer = document.querySelector('.nav-drawer');
|
||||
const overlay = document.querySelector('[data-drawer-overlay]');
|
||||
const hamburger = document.querySelector('.nav-hamburger');
|
||||
const drawerClose = document.querySelector('.nav-drawer-close');
|
||||
|
||||
function openDrawer() {
|
||||
if (!drawer) return;
|
||||
drawer.classList.add('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
overlay.classList.add('is-open');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
hamburger.setAttribute('aria-expanded', 'true');
|
||||
document.body.classList.add('nav-drawer-locked');
|
||||
}
|
||||
function closeDrawer() {
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
overlay.classList.remove('is-open');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
hamburger.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('nav-drawer-locked');
|
||||
}
|
||||
|
||||
if (hamburger) hamburger.addEventListener('click', openDrawer);
|
||||
if (drawerClose) drawerClose.addEventListener('click', closeDrawer);
|
||||
if (overlay) overlay.addEventListener('click', closeDrawer);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && drawer && drawer.classList.contains('is-open')) {
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.nav-drawer a').forEach(link => {
|
||||
link.addEventListener('click', closeDrawer);
|
||||
});
|
||||
|
||||
// ─── Desktop mega-panels (Services / Resources) ───
|
||||
const panelTriggers = document.querySelectorAll('[data-panel-trigger]');
|
||||
const panels = document.querySelectorAll('[data-panel]');
|
||||
|
||||
function closeAllPanels() {
|
||||
panels.forEach(p => p.classList.remove('is-open'));
|
||||
panelTriggers.forEach(t => t.setAttribute('aria-expanded', 'false'));
|
||||
}
|
||||
|
||||
panelTriggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const name = trigger.dataset.panelTrigger;
|
||||
const panel = document.querySelector(`[data-panel="${name}"]`);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.contains('is-open');
|
||||
closeAllPanels();
|
||||
if (!isOpen) {
|
||||
panel.classList.add('is-open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.has-panel')) {
|
||||
closeAllPanels();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllPanels();
|
||||
});
|
||||
document.querySelectorAll('.nav-panel a').forEach(link => {
|
||||
link.addEventListener('click', closeAllPanels);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user