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
|
||||||
|
.env.production
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
*.err
|
*.err
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
|
# 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`
|
index.html # Home (includes contact form at #contact)
|
||||||
- `MongoDB` via the native `mongodb` driver
|
services/ # coaching, consulting, digital-solutions pages
|
||||||
- `docker compose` for local and server runtime
|
resources/ # resources index
|
||||||
|
assets/ # logos
|
||||||
## What changed
|
app.py # Flask app: serves the site + /api/contact (Resend)
|
||||||
|
requirements.txt
|
||||||
- The public site now reads page content from MongoDB when `MONGODB_URI` and `MONGODB_DB` are set.
|
.env.example # copy to .env and fill in (local dev)
|
||||||
- If MongoDB is not configured, the homepage falls back to the local seed file at `src/lib/content/homepage.json`.
|
Dockerfile # python:3.12-slim + gunicorn
|
||||||
- `/admin` is a custom password-protected editor for:
|
docker-compose.yml # single service: lean101, attaches to shared nginx network
|
||||||
- pages
|
deploy.env.template # synced to server .env by deploy.sh (live values win)
|
||||||
- movable page sections
|
deploy.sh # remote deploy: git clone + compose up + nginx + maintenance
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Local development
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
npm install
|
ssh root@<droplet>
|
||||||
npm run dev
|
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
|
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.
|
||||||
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`
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The current contact CTA still uses `mailto:hello@lean-101.com`
|
- 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.
|
||||||
- The seed homepage remains in `src/lib/content/homepage.json`
|
- 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.
|
||||||
- Live page and blog content is stored in MongoDB once the database env vars are configured
|
|
||||||
|
|||||||
@@ -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