From a09d29d9e90470fa04cbd995383a8dc790e7c0ad Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:04:57 -0700 Subject: [PATCH] feat: add CI status indicator and favicon - 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 --- .sops.yaml | 7 +++ app/api/ci-status/route.ts | 57 +++++++++++++++++ app/layout.tsx | 4 ++ components/CIStatusIndicator.tsx | 103 +++++++++++++++++++++++++++++++ components/Header.tsx | 3 + public/rock-svg-icon.svg | 13 ++++ scripts/decrypt-secrets.sh | 23 +++++++ secrets.enc.yaml | 16 +++++ 8 files changed, 226 insertions(+) create mode 100644 .sops.yaml create mode 100644 app/api/ci-status/route.ts create mode 100644 components/CIStatusIndicator.tsx create mode 100644 public/rock-svg-icon.svg create mode 100755 scripts/decrypt-secrets.sh create mode 100644 secrets.enc.yaml diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..3d4b623 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,7 @@ +creation_rules: + - path_regex: \.enc\.yaml$ + age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + - path_regex: \.enc\.json$ + age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + - path_regex: \.enc\.env$ + age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla diff --git a/app/api/ci-status/route.ts b/app/api/ci-status/route.ts new file mode 100644 index 0000000..c6d44e9 --- /dev/null +++ b/app/api/ci-status/route.ts @@ -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 } + ) + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 1061c61..ae18d6c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ diff --git a/components/CIStatusIndicator.tsx b/components/CIStatusIndicator.tsx new file mode 100644 index 0000000..23e3a5d --- /dev/null +++ b/components/CIStatusIndicator.tsx @@ -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(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 + if (!ciStatus || ciStatus.status === 'error') return + if (ciStatus.conclusion === 'success') return + if (ciStatus.conclusion === 'failure') return + if (ciStatus.status === 'in_progress' || ciStatus.status === 'queued') { + return + } + return + } + + 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 = ( + + + CI +
+ {getStatusIcon()} + {getStatusText()} +
+
+ ) + + if (ciStatus?.url) { + return ( + + {content} + + ) + } + + return content +} diff --git a/components/Header.tsx b/components/Header.tsx index bc19339..55084d3 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -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'} + {/* CI Status */} + diff --git a/public/rock-svg-icon.svg b/public/rock-svg-icon.svg new file mode 100644 index 0000000..155dc8d --- /dev/null +++ b/public/rock-svg-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/decrypt-secrets.sh b/scripts/decrypt-secrets.sh new file mode 100755 index 0000000..8a3c20c --- /dev/null +++ b/scripts/decrypt-secrets.sh @@ -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" diff --git a/secrets.enc.yaml b/secrets.enc.yaml new file mode 100644 index 0000000..cbdd0ba --- /dev/null +++ b/secrets.enc.yaml @@ -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