feat: add CI status indicator and favicon
Build & Push Portfolio Image / build-push (push) Successful in 2m40s
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:
@@ -0,0 +1,7 @@
|
||||
creation_rules:
|
||||
- path_regex: \.enc\.yaml$
|
||||
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
- path_regex: \.enc\.json$
|
||||
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
- path_regex: \.enc\.env$
|
||||
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { LanguageProvider } from '@/lib/LanguageContext'
|
||||
export const metadata = {
|
||||
title: 'Rock Liang — Portfolio & Live Infrastructure',
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Menu, X, Moon, Sun } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTerminal } from '@/lib/TerminalContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { CIStatusIndicator } from './CIStatusIndicator'
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -226,6 +227,8 @@ export default function Header() {
|
||||
>
|
||||
{lang === 'en' ? '中文' : 'EN'}
|
||||
</button>
|
||||
{/* CI Status */}
|
||||
<CIStatusIndicator />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -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 |
Executable
+23
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user