feat: add CI status indicator and favicon
Build & Push Portfolio Image / build-push (push) Successful in 2m40s

- Add CI status badge in header (auto-syncs from Forgejo)
- Add rock-svg-icon.svg as favicon for Chrome
- Add SOPS encrypted secrets for Forgejo token
- Add decrypt script for local dev
This commit is contained in:
Story Crater Bot
2026-08-31 22:04:57 -07:00
parent cd8e21200d
commit a09d29d9e9
8 changed files with 226 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
creation_rules:
- path_regex: \.enc\.yaml$
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
- path_regex: \.enc\.json$
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
- path_regex: \.enc\.env$
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
const FORGEJO_URL = 'https://forgejo.riotpiao.com'
const REPO = 'rock/riotpiao.com'
export async function GET() {
const token = process.env.FORGEJO_TOKEN
if (!token) {
return NextResponse.json(
{ error: 'FORGEJO_TOKEN not configured' },
{ status: 500 }
)
}
try {
const response = await fetch(
`${FORGEJO_URL}/api/v1/repos/${REPO}/actions/runs?limit=1`,
{
headers: {
Authorization: `token ${token}`,
Accept: 'application/json',
},
cache: 'no-store',
}
)
if (!response.ok) {
throw new Error(`Forgejo API error: ${response.status}`)
}
const data = await response.json()
const latestRun = data.workflow_runs?.[0]
if (!latestRun) {
return NextResponse.json({
status: 'unknown',
message: 'No runs found',
})
}
return NextResponse.json({
status: latestRun.status,
conclusion: latestRun.conclusion,
title: latestRun.display_title || latestRun.head_branch,
branch: latestRun.head_branch,
url: latestRun.html_url,
updatedAt: latestRun.completed_at || latestRun.started_at,
})
} catch (error) {
console.error('CI status fetch error:', error)
return NextResponse.json(
{ error: 'Failed to fetch CI status', status: 'error' },
{ status: 500 }
)
}
}
+4
View File
@@ -6,6 +6,10 @@ import { LanguageProvider } from '@/lib/LanguageContext'
export const metadata = { export const metadata = {
title: 'Rock Liang — Portfolio & Live Infrastructure', title: 'Rock Liang — Portfolio & Live Infrastructure',
description: 'Personal portfolio showcasing Kubernetes, Terraform, and cloud-native systems.', description: 'Personal portfolio showcasing Kubernetes, Terraform, and cloud-native systems.',
icons: {
icon: '/rock-svg-icon.svg',
apple: '/rock-svg-icon.svg',
},
}; };
export default function RootLayout({ export default function RootLayout({
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { GitBranch, CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react'
interface CIStatus {
status: string
conclusion?: string
title?: string
branch?: string
url?: string
updatedAt?: string
error?: string
}
export function CIStatusIndicator() {
const [ciStatus, setCIStatus] = useState<CIStatus | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await fetch('/api/ci-status')
const data = await res.json()
setCIStatus(data)
} catch {
setCIStatus({ status: 'error', error: 'Failed to fetch' })
} finally {
setLoading(false)
}
}
fetchStatus()
// Poll every 60 seconds
const interval = setInterval(fetchStatus, 60000)
return () => clearInterval(interval)
}, [])
const getStatusIcon = () => {
if (loading) return <Loader2 size={14} className="animate-spin" />
if (!ciStatus || ciStatus.status === 'error') return <AlertCircle size={14} />
if (ciStatus.conclusion === 'success') return <CheckCircle size={14} />
if (ciStatus.conclusion === 'failure') return <XCircle size={14} />
if (ciStatus.status === 'in_progress' || ciStatus.status === 'queued') {
return <Loader2 size={14} className="animate-spin" />
}
return <GitBranch size={14} />
}
const getStatusColor = () => {
if (loading) return 'text-gray-400'
if (!ciStatus || ciStatus.status === 'error') return 'text-gray-400'
if (ciStatus.conclusion === 'success') return 'text-green-500'
if (ciStatus.conclusion === 'failure') return 'text-red-500'
if (ciStatus.status === 'in_progress' || ciStatus.status === 'queued') {
return 'text-yellow-500'
}
return 'text-gray-400'
}
const getStatusText = () => {
if (loading) return 'Checking...'
if (!ciStatus || ciStatus.status === 'error') return 'Unknown'
if (ciStatus.conclusion === 'success') return 'Passing'
if (ciStatus.conclusion === 'failure') return 'Failed'
if (ciStatus.status === 'in_progress') return 'Running'
if (ciStatus.status === 'queued') return 'Queued'
return ciStatus.status
}
const content = (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border border-gray-200 dark:border-gray-700 shadow-sm ${getStatusColor()}`}
>
<GitBranch size={14} className="text-gray-500 dark:text-gray-400" />
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">CI</span>
<div className={`flex items-center gap-1 ${getStatusColor()}`}>
{getStatusIcon()}
<span className="text-xs font-semibold">{getStatusText()}</span>
</div>
</motion.div>
)
if (ciStatus?.url) {
return (
<a
href={ciStatus.url}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform"
title={ciStatus.title || 'View CI run'}
>
{content}
</a>
)
}
return content
}
+3
View File
@@ -6,6 +6,7 @@ import { Menu, X, Moon, Sun } from 'lucide-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTerminal } from '@/lib/TerminalContext' import { useTerminal } from '@/lib/TerminalContext'
import { useLanguage } from '@/lib/LanguageContext' import { useLanguage } from '@/lib/LanguageContext'
import { CIStatusIndicator } from './CIStatusIndicator'
export default function Header() { export default function Header() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -226,6 +227,8 @@ export default function Header() {
> >
{lang === 'en' ? '中文' : 'EN'} {lang === 'en' ? '中文' : 'EN'}
</button> </button>
{/* CI Status */}
<CIStatusIndicator />
</div> </div>
</nav> </nav>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_901_3066)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 10.9999C17 9.89988 16.1 8.99988 15 8.99988H13C11.9 8.99988 11 9.89988 11 10.9999V12.9403H10.9701L8.87005 2.65032C8.68005 1.55932 7.66005 0.839321 6.58005 1.03032L4.63005 1.37932C3.55005 1.57032 2.84005 2.59932 3.02005 3.67932L5.36 16.9999H2C1.45 16.9999 1 17.4499 1 17.9999V22.9999C1 28.9599 4 30.9999 9 30.9999H24C26.33 30.9999 28 29.3299 28 26.9999V19.9999L30.96 3.67991C31.15 2.59991 30.44 1.56991 29.36 1.37991L27.41 1.02991C26.33 0.839909 25.31 1.55991 25.12 2.64991L23.2099 11.9999H23V10.9999C23 9.89988 22.1 8.99988 21 8.99988H19C17.9 8.99988 17 9.89988 17 10.9999Z" fill="#FFE6EA"/>
<path d="M17 11.0009V21.0009C17 22.1049 17.896 23.0009 19 23.0009H21C22.104 23.0009 23 22.1049 23 21.0009V11.0009C23 9.89687 22.104 9.00087 21 9.00087H19C17.896 9.00087 17 9.89687 17 11.0009ZM17 11.0009C17 9.89687 16.104 9.00087 15 9.00087H13C11.896 9.00087 11 9.89687 11 11.0009V14.0009M9.61912 6.98137L8.87312 2.64637C8.68312 1.56237 7.65712 0.838368 6.58012 1.03037L4.63112 1.37637C3.55412 1.56737 2.83612 2.60137 3.02412 3.68437L5.35712 17.0014M24.3701 7.01457L25.1201 2.64657C25.3081 1.56257 26.3351 0.838568 27.4111 1.03057L29.3591 1.37557C30.4351 1.56757 31.1521 2.60157 30.9641 3.68457L28.0001 20.0006V27.0006C28.0001 29.3336 26.3331 31.0006 24.0001 31.0006H9.00012C4.00012 31.0006 1.00012 28.9586 1.00012 23.0006V18.0006C1.00012 17.4486 1.44712 17.0006 2.00012 17.0006H16.0001C16.5311 17.0006 17.0001 17.4386 17.0001 18.0006V19.0006C17.0001 21.3446 15.3441 23.0006 13.0001 23.0006H7.00012" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_901_3066">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>

After

Width:  |  Height:  |  Size: 2.0 KiB

+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Decrypt SOPS secrets to .env.local for local development
# Usage: ./scripts/decrypt-secrets.sh
set -e
SOPS_AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$HOME/.sops/key.txt}"
if [ ! -f "$SOPS_AGE_KEY_FILE" ]; then
echo "Error: Age key not found at $SOPS_AGE_KEY_FILE"
exit 1
fi
if [ ! -f "secrets.enc.yaml" ]; then
echo "Error: secrets.enc.yaml not found"
exit 1
fi
echo "Decrypting secrets..."
SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt secrets.enc.yaml | \
sed 's/: /=/' > .env.local
echo "Created .env.local"
+16
View File
@@ -0,0 +1,16 @@
FORGEJO_TOKEN: ENC[AES256_GCM,data:wMEUmlP6wPMXKqQnBYplCftgx1AWt0efujpspt8LIOawKczkHkf+Dg==,iv:L6lViCudCVI1azLXCZVWT/js0yiX+4j6JFheUvUBw0U=,tag:RqFz8+CWeOvmcVJYFousDg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlVGEzU1kxUzlmQjhxczhx
NUhBQUlsdnNxSWVJUk1TYldBL2Q3MElZZlM4Cmd2eE8zZU9TSkRzRWlkTkdvVE9j
SjJGV0ZKdVl3dSthOG1rbVFRVHhiWW8KLS0tIHBHb2lmZWxWaDUxcjVKYmg0Ui9q
RlFTdGJ0RlYwRjNWZmlBRmx3eDJ2czAK2C2GHNxmp9f/Np0FO02iwUJ+T5wh2kln
PveUdvwB952vP3xsHNIjICE3M5cRE5rYXP0LEgKkWHj5ARGfC2MxMA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-09-01T03:42:34Z"
mac: ENC[AES256_GCM,data:kVHDihKpiI/SRpnnCplaXAI6bDoxmILPEYkoCaokzJIILJZxr568rFRoMF5fQOA7nv1ChVYifrBiluMjDwgphYCw8iZu4zhA7uaD1C6hpS7NhfQvY3fZHEClzcU8wB7KSKMSnnAXbbuWjSUlNcyGy+05WipfzA0r1y/W6hXsurU=,iv:rwQHHNziP2WY1iUvULv4kajOfSbrMmgx9WX5/psEMjE=,tag:CqdgFyeFe0ung2qplofZlw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2