Compare commits
2 Commits
5bcaade558
...
8397e5acf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8397e5acf2 | ||
|
|
7b5fdbd611 |
@@ -1,294 +0,0 @@
|
|||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Snippet } from "@/lib/directus";
|
|
||||||
import { X, Copy, Check, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
|
||||||
|
|
||||||
// Color-coded language badges using the liquid design palette
|
|
||||||
const LANG_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
|
||||||
typescript: { bg: "bg-liquid-lavender/40", text: "text-purple-700 dark:text-purple-300", label: "TS" },
|
|
||||||
ts: { bg: "bg-liquid-lavender/40", text: "text-purple-700 dark:text-purple-300", label: "TS" },
|
|
||||||
javascript: { bg: "bg-liquid-amber/40", text: "text-amber-700 dark:text-amber-300", label: "JS" },
|
|
||||||
js: { bg: "bg-liquid-amber/40", text: "text-amber-700 dark:text-amber-300", label: "JS" },
|
|
||||||
python: { bg: "bg-liquid-sky/40", text: "text-sky-700 dark:text-sky-300", label: "PY" },
|
|
||||||
bash: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" },
|
|
||||||
shell: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" },
|
|
||||||
sh: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" },
|
|
||||||
dockerfile: { bg: "bg-liquid-blue/40", text: "text-blue-700 dark:text-blue-300", label: "🐳" },
|
|
||||||
docker: { bg: "bg-liquid-blue/40", text: "text-blue-700 dark:text-blue-300", label: "🐳" },
|
|
||||||
css: { bg: "bg-liquid-pink/40", text: "text-pink-700 dark:text-pink-300", label: "CSS" },
|
|
||||||
scss: { bg: "bg-liquid-pink/40", text: "text-pink-700 dark:text-pink-300", label: "SCSS" },
|
|
||||||
go: { bg: "bg-liquid-teal/40", text: "text-teal-700 dark:text-teal-300", label: "GO" },
|
|
||||||
rust: { bg: "bg-liquid-peach/40", text: "text-orange-700 dark:text-orange-300", label: "RS" },
|
|
||||||
yaml: { bg: "bg-liquid-lime/40", text: "text-lime-700 dark:text-lime-300", label: "YAML" },
|
|
||||||
json: { bg: "bg-liquid-lime/40", text: "text-lime-700 dark:text-lime-300", label: "JSON" },
|
|
||||||
sql: { bg: "bg-liquid-coral/40", text: "text-red-700 dark:text-red-300", label: "SQL" },
|
|
||||||
nginx: { bg: "bg-liquid-teal/40", text: "text-teal-700 dark:text-teal-300", label: "NGINX" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getLangStyle(language: string) {
|
|
||||||
return LANG_STYLES[language?.toLowerCase()] ?? {
|
|
||||||
bg: "bg-liquid-purple/30",
|
|
||||||
text: "text-purple-700 dark:text-purple-300",
|
|
||||||
label: language?.toUpperCase() || "CODE",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function CodePreview({ code }: { code: string }) {
|
|
||||||
const lines = code.split("\n").slice(0, 4);
|
|
||||||
return (
|
|
||||||
<pre className="mt-4 bg-stone-950/80 rounded-xl p-3 text-[11px] font-mono text-stone-400 overflow-hidden leading-relaxed border border-stone-800/60 select-none">
|
|
||||||
{lines.map((line, i) => (
|
|
||||||
<div key={i} className="truncate">{line || " "}</div>
|
|
||||||
))}
|
|
||||||
{code.split("\n").length > 4 && (
|
|
||||||
<div className="text-stone-600 text-[10px] mt-1">…</div>
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
|
||||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [activeCategory, setActiveCategory] = useState<string>("All");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
// Derived data
|
|
||||||
const categories = useMemo(() => {
|
|
||||||
const cats = Array.from(new Set(initialSnippets.map((s) => s.category))).sort();
|
|
||||||
return ["All", ...cats];
|
|
||||||
}, [initialSnippets]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
return initialSnippets.filter((s) => {
|
|
||||||
const matchCat = activeCategory === "All" || s.category === activeCategory;
|
|
||||||
const matchSearch =
|
|
||||||
!q ||
|
|
||||||
s.title.toLowerCase().includes(q) ||
|
|
||||||
s.description.toLowerCase().includes(q) ||
|
|
||||||
s.category.toLowerCase().includes(q) ||
|
|
||||||
s.language.toLowerCase().includes(q);
|
|
||||||
return matchCat && matchSearch;
|
|
||||||
});
|
|
||||||
}, [initialSnippets, activeCategory, search]);
|
|
||||||
|
|
||||||
// Language badge for the currently open modal
|
|
||||||
const modalLang = useMemo(
|
|
||||||
() => (selectedSnippet ? getLangStyle(selectedSnippet.language) : null),
|
|
||||||
[selectedSnippet]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keyboard nav: ESC + arrows
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
if (!selectedSnippet) return;
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setSelectedSnippet(null);
|
|
||||||
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
||||||
const idx = filtered.findIndex((s) => s.id === selectedSnippet.id);
|
|
||||||
if (idx < filtered.length - 1) setSelectedSnippet(filtered[idx + 1]);
|
|
||||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
||||||
const idx = filtered.findIndex((s) => s.id === selectedSnippet.id);
|
|
||||||
if (idx > 0) setSelectedSnippet(filtered[idx - 1]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedSnippet, filtered]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [handleKeyDown]);
|
|
||||||
|
|
||||||
const copyToClipboard = useCallback((code: string) => {
|
|
||||||
navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentIndex = selectedSnippet
|
|
||||||
? filtered.findIndex((s) => s.id === selectedSnippet.id)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* ── Filter & Search bar ── */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-10">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search snippets…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 focus:outline-none focus:border-liquid-purple transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category chips */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => setActiveCategory(cat)}
|
|
||||||
className={`px-4 py-2 rounded-2xl text-[11px] font-black uppercase tracking-widest border transition-all ${
|
|
||||||
activeCategory === cat
|
|
||||||
? "bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 border-stone-900 dark:border-stone-50 shadow-md"
|
|
||||||
: "bg-white dark:bg-stone-900 text-stone-500 dark:text-stone-400 border-stone-200 dark:border-stone-800 hover:border-liquid-purple hover:text-liquid-purple"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Empty state ── */}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<p className="text-center text-stone-400 py-24 text-sm">
|
|
||||||
No snippets found{search ? ` for "${search}"` : ""}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Snippet Grid ── */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{filtered.map((s, i) => {
|
|
||||||
const lang = getLangStyle(s.language);
|
|
||||||
return (
|
|
||||||
<motion.button
|
|
||||||
key={s.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.04 }}
|
|
||||||
onClick={() => setSelectedSnippet(s)}
|
|
||||||
className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group flex flex-col"
|
|
||||||
>
|
|
||||||
{/* Header row: category + language badge */}
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400 group-hover:text-liquid-purple transition-colors">
|
|
||||||
{s.category}
|
|
||||||
</span>
|
|
||||||
{s.language && (
|
|
||||||
<span className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-wider ${lang.bg} ${lang.text}`}>
|
|
||||||
{lang.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h3 className="text-xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-2 group-hover:text-liquid-purple transition-colors leading-tight">
|
|
||||||
{s.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed flex-1">
|
|
||||||
{s.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Mini code preview */}
|
|
||||||
<CodePreview code={s.code} />
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Snippet Modal ── */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedSnippet && modalLang && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setSelectedSnippet(null)}
|
|
||||||
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
key={selectedSnippet.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 16 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 16 }}
|
|
||||||
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
|
||||||
>
|
|
||||||
<div className="p-8 md:p-10 overflow-y-auto">
|
|
||||||
{/* Modal header */}
|
|
||||||
<div className="flex justify-between items-start mb-6">
|
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
|
||||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple">
|
|
||||||
{selectedSnippet.category}
|
|
||||||
</p>
|
|
||||||
{selectedSnippet.language && (
|
|
||||||
<span className={`px-2.5 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-wider ${modalLang.bg} ${modalLang.text}`}>
|
|
||||||
{modalLang.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter leading-tight">
|
|
||||||
{selectedSnippet.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedSnippet(null)}
|
|
||||||
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors shrink-0"
|
|
||||||
title="Close (Esc)"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
|
||||||
{selectedSnippet.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Code block */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute top-4 right-4 flex gap-2 z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(selectedSnippet.code)}
|
|
||||||
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
|
||||||
title="Copy code"
|
|
||||||
>
|
|
||||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
|
||||||
<code>{selectedSnippet.code}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal footer: navigation */}
|
|
||||||
<div className="px-8 py-5 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => currentIndex > 0 && setSelectedSnippet(filtered[currentIndex - 1])}
|
|
||||||
disabled={currentIndex <= 0}
|
|
||||||
className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
title="Previous (←)"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> Prev
|
|
||||||
</button>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-300 dark:text-stone-600 tabular-nums">
|
|
||||||
{currentIndex + 1} / {filtered.length}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => currentIndex < filtered.length - 1 && setSelectedSnippet(filtered[currentIndex + 1])}
|
|
||||||
disabled={currentIndex >= filtered.length - 1}
|
|
||||||
className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
title="Next (→)"
|
|
||||||
>
|
|
||||||
Next <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
|
|
||||||
import React from "react";
|
|
||||||
import { getSnippets } from "@/lib/directus";
|
|
||||||
import { Terminal, ArrowLeft } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import SnippetsClient from "./SnippetsClient";
|
|
||||||
|
|
||||||
export default async function SnippetsPage({ params }: { params: Promise<{ locale: string }> }) {
|
|
||||||
const { locale } = await params;
|
|
||||||
const snippets = await getSnippets(100) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 transition-colors duration-500">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}`}
|
|
||||||
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all mb-12 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={14} className="group-hover:-translate-x-1 transition-transform" />
|
|
||||||
Back to Portfolio
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<header className="mb-20">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900">
|
|
||||||
<Terminal size={24} />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
|
||||||
The Lab<span className="text-liquid-purple">.</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-2xl leading-relaxed">
|
|
||||||
A collection of technical snippets, configurations, and mental notes from my daily building process.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SnippetsClient initialSnippets={snippets} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getSnippets } from '@/lib/directus';
|
|
||||||
|
|
||||||
const CACHE_TTL = 300; // 5 minutes
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
|
||||||
const featured = searchParams.get('featured') === 'true' ? true : undefined;
|
|
||||||
|
|
||||||
const snippets = await getSnippets(limit, featured);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ snippets: snippets || [] },
|
|
||||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
|
||||||
);
|
|
||||||
} catch (_error) {
|
|
||||||
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,13 @@ import dynamic from "next/dynamic";
|
|||||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
import ReadBooks from "./ReadBooks";
|
import ReadBooks from "./ReadBooks";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { TechStackCategory, TechStackItem, Hobby, Snippet } from "@/lib/directus";
|
import { TechStackCategory, TechStackItem, Hobby } from "@/lib/directus";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ActivityFeed from "./ActivityFeed";
|
import ActivityFeed from "./ActivityFeed";
|
||||||
import BentoChat from "./BentoChat";
|
import BentoChat from "./BentoChat";
|
||||||
import { Skeleton } from "./ui/Skeleton";
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
import { LucideIcon, X, Copy, Check } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
const iconMap: Record<string, LucideIcon> = {
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
||||||
@@ -25,21 +25,17 @@ const About = () => {
|
|||||||
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
|
||||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
|
const [cmsRes, techRes, hobbiesRes, msgRes] = await Promise.all([
|
||||||
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||||
fetch(`/api/tech-stack?locale=${locale}`),
|
fetch(`/api/tech-stack?locale=${locale}`),
|
||||||
fetch(`/api/hobbies?locale=${locale}`),
|
fetch(`/api/hobbies?locale=${locale}`),
|
||||||
fetch(`/api/messages?locale=${locale}`),
|
fetch(`/api/messages?locale=${locale}`)
|
||||||
fetch(`/api/snippets?limit=3&featured=true`)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cmsData = await cmsRes.json();
|
const cmsData = await cmsRes.json();
|
||||||
@@ -53,9 +49,6 @@ const About = () => {
|
|||||||
|
|
||||||
const msgData = await msgRes.json();
|
const msgData = await msgRes.json();
|
||||||
if (msgData?.messages) setCmsMessages(msgData.messages);
|
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||||
|
|
||||||
const snippetsData = await snippetsRes.json();
|
|
||||||
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("About data fetch failed:", error);
|
console.error("About data fetch failed:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,12 +58,6 @@ const About = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const copyToClipboard = (code: string) => {
|
|
||||||
navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -169,96 +156,61 @@ const About = () => {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 5. Library, Gear & Snippets */}
|
{/* 5. Library */}
|
||||||
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
<motion.div
|
||||||
{/* Library - Larger Span */}
|
transition={{ delay: 0.4 }}
|
||||||
<motion.div
|
className="md:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
||||||
transition={{ delay: 0.4 }}
|
>
|
||||||
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
>
|
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
||||||
<div className="relative z-10 flex flex-col h-full">
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
||||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
<BookOpen className="text-liquid-purple" size={24} /> Library
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
</h3>
|
||||||
<BookOpen className="text-liquid-purple" size={24} /> Library
|
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||||
</h3>
|
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||||
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
</Link>
|
||||||
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
</div>
|
||||||
</Link>
|
<CurrentlyReading />
|
||||||
</div>
|
<div className="mt-6 flex-1">
|
||||||
<CurrentlyReading />
|
<ReadBooks />
|
||||||
<div className="mt-6 flex-1">
|
</div>
|
||||||
<ReadBooks />
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 6. My Gear */}
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="md:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8"
|
||||||
|
>
|
||||||
|
<div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
||||||
|
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
||||||
|
|
||||||
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
|
|
||||||
{/* My Gear (Uses) */}
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
|
||||||
>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
|
||||||
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
|
||||||
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
transition={{ delay: 0.6 }}
|
|
||||||
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
|
|
||||||
>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter mb-4 sm:mb-6">
|
|
||||||
<Terminal className="text-liquid-purple" size={24} /> Snippets
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-12 rounded-xl" />)
|
|
||||||
) : snippets.length > 0 ? (
|
|
||||||
snippets.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.id}
|
|
||||||
onClick={() => setSelectedSnippet(s)}
|
|
||||||
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
|
||||||
>
|
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
|
||||||
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-stone-500 dark:text-stone-400 italic">No snippets yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
|
||||||
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 6. Hobbies */}
|
{/* 7. Hobbies */}
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={{ delay: 0.5 }}
|
transition={{ delay: 0.5 }}
|
||||||
className="md:col-span-12"
|
className="md:col-span-12"
|
||||||
@@ -293,69 +245,6 @@ const About = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Snippet Modal */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedSnippet && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setSelectedSnippet(null)}
|
|
||||||
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
||||||
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
|
||||||
>
|
|
||||||
<div className="p-5 sm:p-8 md:p-10 overflow-y-auto">
|
|
||||||
<div className="flex justify-between items-start mb-5 sm:mb-8">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
|
|
||||||
<h3 className="text-xl sm:text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedSnippet(null)}
|
|
||||||
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm sm:text-base text-stone-600 dark:text-stone-400 mb-5 sm:mb-8 leading-relaxed">
|
|
||||||
{selectedSnippet.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="relative group/code">
|
|
||||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(selectedSnippet.code)}
|
|
||||||
className="p-2 sm:p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
|
||||||
title="Copy Code"
|
|
||||||
>
|
|
||||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="bg-stone-950 p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-x-auto text-xs sm:text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
|
||||||
<code>{selectedSnippet.code}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedSnippet(null)}
|
|
||||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Close Laboratory
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowLeft, Search, Terminal } from "lucide-react";
|
import { ArrowLeft, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -25,7 +25,7 @@ export default function NotFound() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
|
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
|
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
|
||||||
@@ -58,49 +58,26 @@ export default function NotFound() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Sidebar Cards */}
|
{/* Explore Work Card */}
|
||||||
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6">
|
<motion.div
|
||||||
{/* Search/Explore Projects */}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
<motion.div
|
animate={{ opacity: 1, x: 0 }}
|
||||||
initial={{ opacity: 0, x: 20 }}
|
transition={{ delay: 0.1 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
className="md:col-span-12 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex flex-col justify-between min-h-[200px]"
|
||||||
transition={{ delay: 0.1 }}
|
>
|
||||||
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
|
<div className="relative z-10">
|
||||||
|
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
|
||||||
|
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/projects"
|
||||||
|
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
View Projects <ArrowLeft className="rotate-180" size={14} />
|
||||||
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
|
</Link>
|
||||||
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
|
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
|
||||||
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
</motion.div>
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/projects"
|
|
||||||
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
|
|
||||||
>
|
|
||||||
View Projects <ArrowLeft className="rotate-180" size={14} />
|
|
||||||
</Link>
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Visit the Lab */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
|
|
||||||
>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
|
|
||||||
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
|
|
||||||
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/snippets"
|
|
||||||
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Enter the Lab <ArrowLeft className="rotate-180" size={14} />
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -937,63 +937,6 @@ export async function getProjectBySlug(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snippets Types
|
|
||||||
export interface Snippet {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
category: string;
|
|
||||||
code: string;
|
|
||||||
description: string;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Snippets from Directus
|
|
||||||
*/
|
|
||||||
export async function getSnippets(limit = 10, featured?: boolean): Promise<Snippet[] | null> {
|
|
||||||
const filters = ['status: { _eq: "published" }'];
|
|
||||||
if (featured !== undefined) {
|
|
||||||
filters.push(`featured: { _eq: ${featured} }`);
|
|
||||||
}
|
|
||||||
const filterString = `filter: { _and: [{ ${filters.join(' }, { ')} }] }`;
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query {
|
|
||||||
snippets(
|
|
||||||
${filterString}
|
|
||||||
limit: ${limit}
|
|
||||||
) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
category
|
|
||||||
code
|
|
||||||
description
|
|
||||||
language
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await directusRequest(
|
|
||||||
'',
|
|
||||||
{ body: { query } }
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SnippetsResult {
|
|
||||||
snippets: Snippet[];
|
|
||||||
}
|
|
||||||
const snippets = (result as SnippetsResult | null)?.snippets;
|
|
||||||
if (!snippets || snippets.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return snippets;
|
|
||||||
} catch (_error) {
|
|
||||||
console.error('Failed to fetch snippets:', _error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
|
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
|
||||||
|
|
||||||
export interface BookReviewCreate {
|
export interface BookReviewCreate {
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Docker Event - Callback Handler",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"updates": [
|
|
||||||
"callback_query"
|
|
||||||
],
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegramTrigger",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
-880,
|
|
||||||
288
|
|
||||||
],
|
|
||||||
"id": "a56a5174-3ccf-492f-810b-117be933560c",
|
|
||||||
"name": "Telegram Trigger",
|
|
||||||
"webhookId": "6e70b9ab-b76b-48dc-8e4d-5fe1bf0d7e39",
|
|
||||||
"credentials": {
|
|
||||||
"telegramApi": {
|
|
||||||
"id": "ADurvy9EKUDzbDdq",
|
|
||||||
"name": "DK0_Server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const callback = $input.first().json;\nconst data = callback.callback_query?.data || '';\nconst chatId = callback.callback_query?.from?.id;\nconst messageId = callback.callback_query?.message?.message_id;\n\n// Parse: auto:slug, manual:slug, ignore:slug\nconst [action, slug] = data.split(':');\n\nreturn [{\n json: {\n action,\n slug,\n chatId,\n messageId,\n rawCallback: data\n }\n}];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
-656,
|
|
||||||
288
|
|
||||||
],
|
|
||||||
"id": "10e5a475-4194-4919-9186-1eb052fbd79b",
|
|
||||||
"name": "Parse Callback"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"rules": {
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.action }}",
|
|
||||||
"rightValue": "auto",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "Auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.action }}",
|
|
||||||
"rightValue": "manual",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "Manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.action }}",
|
|
||||||
"rightValue": "ignore",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "Ignore"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.switch",
|
|
||||||
"typeVersion": 3.2,
|
|
||||||
"position": [
|
|
||||||
-448,
|
|
||||||
288
|
|
||||||
],
|
|
||||||
"id": "a533e527-b3c5-4946-9a26-6f499c7dd6c5",
|
|
||||||
"name": "Switch Action"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://cms.dk0.dev/items/projects?filter[slug][_eq]={{ $json.slug }}&limit=1",
|
|
||||||
"authentication": "predefinedCredentialType",
|
|
||||||
"nodeCredentialType": "httpBearerAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
-224,
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"id": "9fc55503-e890-4074-9823-f07001b6948a",
|
|
||||||
"name": "Get Project from CMS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://git.dk0.dev/api/v1/repos/denshooter/{{ $json.slug }}/commits?limit=3",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"id": "a3fda0d9-0cc9-4744-be3e-9a95ef44dfb4",
|
|
||||||
"name": "Get Commits"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://git.dk0.dev/api/v1/repos/denshooter/{{ $json.slug }}/contents/README.md",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
128
|
|
||||||
],
|
|
||||||
"id": "7106b8c9-fb20-46d9-9e4e-06882115bf7a",
|
|
||||||
"name": "Get README"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"model": "openrouter/free",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
448,
|
|
||||||
192
|
|
||||||
],
|
|
||||||
"id": "9acce2c3-1a26-450f-a263-0dc3a1f1e3cf",
|
|
||||||
"name": "OpenRouter Chat Model"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"promptType": "define",
|
|
||||||
"text": "=Du bist ein technischer Autor für das Portfolio von Dennis (dk0.dev).\n\nNeues eigenes Projekt deployed:\nRepo: {{ $('Parse Callback').item.json.slug }}\n\nREADME:\n{{ $('Get README').first().json.content ? Buffer.from($('Get README').first().json.content, 'base64').toString('utf8').substring(0, 1000) : 'Kein README' }}\n\nLetzte Commits:\n{{ $('Get Commits').first().json.map(c => '- ' + c.commit.message).join('\\n') }}\n\nErstelle eine Portfolio-Beschreibung:\n- Was macht das Projekt (Features, Zweck)\n- Tech-Stack und Architektur\n- Highlights aus den Commits\n- Warum ist es cool/interessant\n\nKategorie: webdev (wenn Web-App), automation (wenn Tool/Script), oder selfhosted\n\nAntworte NUR als JSON:\n{\n \"title_en\": \"Aussagekräftiger Titel\",\n \"title_de\": \"Aussagekräftiger Titel\",\n \"description_en\": \"4-6 Sätze\",\n \"description_de\": \"4-6 Sätze\",\n \"content_en\": \"2-3 Absätze Markdown mit technischen Details\",\n \"content_de\": \"2-3 Absätze Markdown mit technischen Details\",\n \"category\": \"webdev|automation|selfhosted\",\n \"technologies\": [\"Next.js\", \"Docker\", \"...\"]\n}",
|
|
||||||
"batching": {},
|
|
||||||
"prompt": "\n Verwende keine Bindestriche, Em-Dashes oder Gedankenstriche (–, —, -)."
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
|
||||||
"typeVersion": 1.9,
|
|
||||||
"position": [
|
|
||||||
224,
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"id": "2b011cf8-6ed3-4cb1-ab6f-7727912864fc",
|
|
||||||
"name": "AI: Generate Description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const raw = $input.first().json.text ?? \"\";\nconst match = raw.match(/\\{[\\s\\S]*\\}/);\nif (!match) throw new Error(\"No JSON found\");\nconst ai = JSON.parse(match[0]);\nreturn [{ json: ai }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
448,
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"id": "0cbdcf6e-e5d4-460e-b345-b6d47deed051",
|
|
||||||
"name": "Parse JSON"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const ai = $input.first().json;\nconst ctx = $('Parse Callback').first().json;\n\nconst body = {\n slug: ctx.slug,\n status: \"draft\",\n featured: false,\n title: ai.title_en,\n category: ai.category,\n technologies: ai.technologies,\n tags: ai.technologies,\n date: new Date().toISOString().slice(0, 10),\n translations: {\n create: [\n {\n languages_code: \"en-US\",\n title: ai.title_en,\n description: ai.description_en,\n content: ai.content_en\n },\n {\n languages_code: \"de-DE\",\n title: ai.title_de,\n description: ai.description_de,\n content: ai.content_de\n }\n ]\n }\n};\n\nconst response = await this.helpers.httpRequest({\n method: \"POST\",\n url: \"https://cms.dk0.dev/items/projects\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Authorization\": \"Bearer RF2QytqhcLXuVy6FO3PzWlsoR-ysCTwB\"\n },\n body\n});\n\nreturn [{ json: response }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
672,
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"id": "70aecf97-6b70-4f03-99e3-9ee44fc0830b",
|
|
||||||
"name": "Add to Directus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "={{ $('Parse Callback').item.json.chatId }}",
|
|
||||||
"text": "={{ \n'✅ Projekt erstellt: ' + $json.data.title + '\\n\\n' +\n'📝 ' + $('Parse JSON').first().json.description_de.substring(0, 200) + '...\\n\\n' +\n'Status: Draft (ID: ' + $json.data.id + ')\\n\\n' +\n'/publishproject' + $json.data.id + ' — Veröffentlichen\\n' + \n'/deleteproject' + $json.data.id + ' — Löschen' \n}}",
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
880,
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"id": "9a353247-7d25-4330-9cbf-580599428ae1",
|
|
||||||
"name": "Notify Success",
|
|
||||||
"webhookId": "b1d7284d-c2e5-4e87-b65d-272f1b9b8d6d"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "={{ $json.chatId }}",
|
|
||||||
"text": "✍️ OK, schreib mir jetzt was das Projekt macht (4-6 Sätze).\n\nIch formatiere das dann schön und erstelle einen Draft.",
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
-224,
|
|
||||||
288
|
|
||||||
],
|
|
||||||
"id": "9160b847-5f07-4d64-9488-faeaeca926b9",
|
|
||||||
"name": "Ask for Manual Input",
|
|
||||||
"webhookId": "c4cb518d-a2e2-48af-b9b6-c3f645fd37db"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "={{ $json.chatId }}",
|
|
||||||
"text": "❌ OK, ignoriert.",
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
-224,
|
|
||||||
480
|
|
||||||
],
|
|
||||||
"id": "1624b6f1-8202-4fd2-bd0a-52fa039ca696",
|
|
||||||
"name": "Confirm Ignore",
|
|
||||||
"webhookId": "4c5248f1-4420-403c-a506-2e1968c5579d",
|
|
||||||
"credentials": {
|
|
||||||
"telegramApi": {
|
|
||||||
"id": "ADurvy9EKUDzbDdq",
|
|
||||||
"name": "DK0_Server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"Telegram Trigger": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse Callback",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse Callback": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Switch Action",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Switch Action": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get Project from CMS",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Ask for Manual Input",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Confirm Ignore",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Get Project from CMS": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get Commits",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Get Commits": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get README",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Get README": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI: Generate Description",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"OpenRouter Chat Model": {
|
|
||||||
"ai_languageModel": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI: Generate Description",
|
|
||||||
"type": "ai_languageModel",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AI: Generate Description": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse JSON",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse JSON": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Add to Directus",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Add to Directus": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Notify Success",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": false,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate",
|
|
||||||
"availableInMCP": false
|
|
||||||
},
|
|
||||||
"versionId": "4636a407-7f8e-4833-9345-9d3296ec9b74",
|
|
||||||
"meta": {
|
|
||||||
"instanceId": "cb28e4db755465d5826da179e87f69603d81f833414cc52c327be9183a217b8d"
|
|
||||||
},
|
|
||||||
"id": "abnrtUuJ7BAWv9Hm",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
@@ -1,937 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Docker Event (Extended)",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"path": "docker-event",
|
|
||||||
"responseMode": "responseNode",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2.1,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "870fa550-42f6-4e19-a796-f1f044b0cdc8",
|
|
||||||
"name": "Webhook",
|
|
||||||
"webhookId": "e147d70b-79d8-44fd-bbe8-8274cf905b11",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const data = $input.first().json;\n\nconst container = data.container ?? data.body?.container ?? '';\nconst image = data.image ?? data.body?.image ?? '';\nconst timestamp = data.timestamp ?? data.body?.timestamp ?? '';\n\nconst slug = container.toLowerCase().replace(/[^a-z0-9]+/g, '-');\n\nconst serviceName = container.replace(/[-_]/g, ' ');\n\nreturn [{\n json: {\n container,\n image,\n serviceName,\n timestamp,\n slug \n }\n}];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
224,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "aaa6a678-1ad3-4f82-9b01-37e21b47b189",
|
|
||||||
"name": "Kontext aufbereiten",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "loose",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "ebe26f0c-d5a7-45c9-9747-afc75b57a41c",
|
|
||||||
"leftValue": "={{ $json.data }}",
|
|
||||||
"rightValue": "[]",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "notEndsWith"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"looseTypeValidation": true,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2.3,
|
|
||||||
"position": [
|
|
||||||
672,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "62197a33-5169-48e1-9539-57c047efb108",
|
|
||||||
"name": "If",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://cms.dk0.dev/items/projects?filter[slug][_eq]={{ $json.slug }}&limit=1",
|
|
||||||
"authentication": "predefinedCredentialType",
|
|
||||||
"nodeCredentialType": "httpBearerAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
448,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "db783886-06b5-4473-8907-dd6c655aa3dd",
|
|
||||||
"name": "Search for Slug",
|
|
||||||
"credentials": {
|
|
||||||
"httpBearerAuth": {
|
|
||||||
"id": "ZtI5e08iryR9m6FG",
|
|
||||||
"name": "Directus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"model": "openrouter/free",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
976,
|
|
||||||
16
|
|
||||||
],
|
|
||||||
"id": "b9130ff4-359b-4736-9442-1b0ca7d31877",
|
|
||||||
"name": "OpenRouter Chat Model",
|
|
||||||
"credentials": {
|
|
||||||
"openRouterApi": {
|
|
||||||
"id": "8Kdy4RHHwMZ0Cn6x",
|
|
||||||
"name": "OpenRouter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"promptType": "define",
|
|
||||||
"text": "= Du bist ein technischer Autor für das Self-Hosting Portfolio von Dennis auf dk0.dev.\n Ein neuer Service wurde auf dem Server deployed:\n \n Container: {{ $('Kontext aufbereiten').item.json.container }}\n Image: {{ $('Kontext aufbereiten').item.json.image }}\n Service: {{ $('Kontext aufbereiten').item.json.serviceName }}\n \n Aufgabe:\n 1. Erkenne ob es sich um ein EIGENES Projekt (z.B. Image enthält \"denshooter\", \"dk0\", \"portfolio\") oder eine \nSELF-HOSTED App handelt.\n 2. Bewerte die \"Coolness\" (1-10) basierend auf:\n - Eigener Code = +3 Punkte\n - Neue/spannende Technologie = +2 Punkte\n - Großes/bekanntes Projekt (Suricata, CrowdStrike-Level) = +3 Punkte\n - Standard Self-Hosted Tool (Nextcloud, Plausible) = +1 Punkt\n - CI/CD Build-Container, Test-Runner = 0 Punkte (ignorieren)\n 3. Erstelle Beschreibung NUR wenn coolness_score >= 6\n \n Antworte NUR als valides JSON:\n {\n \"coolness_score\": 1-10,\n \"notify\": true/false (true wenn >= 7),\n \"reason\": \"Kurze Begründung warum cool oder nicht\",\n \"type\": \"own\" oder \"selfhosted\" oder \"ignore\",\n \"title_en\": \"...\",\n \"title_de\": \"...\",\n \"description_en\": \"...\",\n \"description_de\": \"...\",\n \"content_en\": \"...\",\n \"content_de\": \"...\",\n \"category\": \"selfhosted\" oder \"webdev\" oder \"automation\",\n \"technologies\": [\"Docker\", \"...\"]\n }",
|
|
||||||
"batching": {},
|
|
||||||
"prompt": "\n Verwende keine Bindestriche, Em-Dashes oder Gedankenstriche (–, —, -)."
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
|
||||||
"typeVersion": 1.9,
|
|
||||||
"position": [
|
|
||||||
896,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "77d46075-3342-4e93-8806-07087a2389dc",
|
|
||||||
"name": "Basic LLM Chain",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const raw = $input.first().json.text ?? \"\";\n\nconst match = raw.match(/\\{[\\s\\S]*\\}/);\nif (!match) throw new Error(\"No JSON found\");\n\nconst ai = JSON.parse(match[0]);\n\nreturn [\n {\n json: ai,\n },\n];\n"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
1248,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "de5ed311-0d46-4677-963c-711a6ad514e9",
|
|
||||||
"name": "Parse JSON",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const ai = $('Parse JSON').first().json;\n const ctx = $('Kontext aufbereiten').first().json;\n\n const body = {\n slug: ctx.slug,\n status: \"draft\",\n featured: false,\n title: ai.title_en,\n category: ai.category,\n technologies: ai.technologies,\n tags: ai.technologies,\n date: new Date().toISOString().slice(0, 10),\n translations: {\n create: [\n {\n languages_code: \"en-US\",\n title: ai.title_en,\n description: ai.description_en,\n content: ai.content_en\n },\n {\n languages_code: \"de-DE\",\n title: ai.title_de,\n description: ai.description_de,\n content: ai.content_de\n }\n ]\n }\n };\n\n const response = await this.helpers.httpRequest({\n method: \"POST\",\n url: \"https://cms.dk0.dev/items/projects\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Authorization\": \"Bearer RF2QytqhcLXuVy6FO3PzWlsoR-ysCTwB\"\n },\n body\n });\n\n return [{ json: response }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
1680,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "c47b915d-e4d7-43e9-8ee3-b41389896fa7",
|
|
||||||
"name": "Add to Directus",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "{ \"success\": true }",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
2128,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "6cf8f30d-1352-466f-9163-9b4f16b972e0",
|
|
||||||
"name": "Respond to Webhook",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "145931600",
|
|
||||||
"text": "={{ \n'🆕 Neuer Service erkannt!\\n\\n' +\n'📦 ' + $('Kontext aufbereiten').first().json.container + '\\n' +\n'🐳 ' + $('Kontext aufbereiten').first().json.image + '\\n\\n' +\n'📝 ' + $('Parse JSON').first().json.title_de + '\\n' + \n$('Parse JSON').first().json.description_de + '\\n\\n' +\n'Status: Draft in Directus erstellt (ID: ' + $json.data.id + ')\\n\\n' +\n('/publishproject_' + $json.data.id).replace(/_/g, '\\\\_') + ' — Veröffentlichen\\n' + \n('/deleteproject_' + $json.data.id).replace(/_/g, '\\\\_') + ' — Löschen' \n}}",
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
1904,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "b29de3ec-b1ca-40c3-8493-af44e5372fd2",
|
|
||||||
"name": "Send a text message",
|
|
||||||
"webhookId": "c02ccf69-16dc-436e-b1cc-f8fa9dd8d33f",
|
|
||||||
"credentials": {
|
|
||||||
"telegramApi": {
|
|
||||||
"id": "ADurvy9EKUDzbDdq",
|
|
||||||
"name": "DK0_Server"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"rules": {
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "strict",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.notify }}",
|
|
||||||
"rightValue": "true",
|
|
||||||
"operator": {
|
|
||||||
"type": "boolean",
|
|
||||||
"operation": "true",
|
|
||||||
"singleValue": true
|
|
||||||
},
|
|
||||||
"id": "febc397c-b060-4a66-ab9b-1274c8509cc2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.switch",
|
|
||||||
"typeVersion": 3.4,
|
|
||||||
"position": [
|
|
||||||
1456,
|
|
||||||
-224
|
|
||||||
],
|
|
||||||
"id": "5ade115f-e134-4358-8d95-a144eede8d9a",
|
|
||||||
"name": "Switch",
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const data = $input.first().json;\n\nconst container = data.container ?? data.body?.container ?? '';\nconst image = data.image ?? data.body?.image ?? '';\nconst timestamp = data.timestamp ?? data.body?.timestamp ?? '';\n\nconst slug = container.toLowerCase().replace(/[^a-z0-9]+/g, '-');\nconst serviceName = container.replace(/[-_]/g, ' ');\n\n// Detect project type\nlet projectType = 'selfhosted';\nif (image.includes('denshooter') || image.includes('dk0')) {\n projectType = 'own';\n} else if (container.match(/^(act-|gitea-actions-|runner-)/)) {\n projectType = 'cicd';\n}\n\n// Extract repo from image for own projects\nlet repo = null;\nif (projectType === 'own') {\n const match = image.match(/([^/]+):(\\w+)/);\n if (match) repo = match[1];\n}\n\nreturn [{\n json: {\n container,\n image,\n serviceName,\n timestamp,\n slug,\n projectType,\n repo\n }\n}];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
896,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "fb34f047-5c11-4255-9b45-adb9fe169042",
|
|
||||||
"name": "Parse Context"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://cms.dk0.dev/items/projects?filter[slug][_eq]={{ $json.slug }}&limit=1",
|
|
||||||
"authentication": "predefinedCredentialType",
|
|
||||||
"nodeCredentialType": "httpBearerAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
1120,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "acd7a411-2465-4aa3-a7ee-442a79c500f2",
|
|
||||||
"name": "Check if Exists",
|
|
||||||
"credentials": {
|
|
||||||
"httpBearerAuth": {
|
|
||||||
"id": "ZtI5e08iryR9m6FG",
|
|
||||||
"name": "Directus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "loose"
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.data.length }}",
|
|
||||||
"rightValue": "0",
|
|
||||||
"operator": {
|
|
||||||
"type": "number",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2.3,
|
|
||||||
"position": [
|
|
||||||
1344,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "bdcddb94-8676-4467-a370-ad2cf07d09a3",
|
|
||||||
"name": "If New"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"rules": {
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $('Parse Context').item.json.projectType }}",
|
|
||||||
"rightValue": "own",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "Own Project"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $('Parse Context').item.json.projectType }}",
|
|
||||||
"rightValue": "cicd",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "CI/CD (Ignore)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": ""
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $('Parse Context').item.json.projectType }}",
|
|
||||||
"rightValue": "selfhosted",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "Self-Hosted"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.switch",
|
|
||||||
"typeVersion": 3.2,
|
|
||||||
"position": [
|
|
||||||
1568,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "00786826-8d6b-4e17-aa7f-1afdca38d7a3",
|
|
||||||
"name": "Switch Type"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://git.dk0.dev/api/v1/repos/denshooter/{{ $('Parse Context').item.json.repo }}/commits?limit=1",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
1776,
|
|
||||||
560
|
|
||||||
],
|
|
||||||
"id": "9ef7f66b-3054-4765-b0a8-7ebb6aa353aa",
|
|
||||||
"name": "Get Last Commit",
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "YN3oIbok6Fjy5WNW",
|
|
||||||
"name": "gitea api"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "=https://git.dk0.dev/api/v1/repos/denshooter/{{ $('Parse Context').item.json.repo }}/contents/README.md",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
1840,
|
|
||||||
672
|
|
||||||
],
|
|
||||||
"id": "114fece9-c5f1-4c6b-8272-6f39fb8ce24a",
|
|
||||||
"name": "Get README",
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "YN3oIbok6Fjy5WNW",
|
|
||||||
"name": "gitea api"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const ctx = $('Parse Context').first().json;\nconst commit = $('Get Last Commit').first().json[0];\nconst readme = $('Get README').first().json;\n\n// Decode README (base64)\nlet readmeText = '';\ntry {\n readmeText = Buffer.from(readme.content, 'base64').toString('utf8');\n // First 500 chars\n readmeText = readmeText.substring(0, 500).replace(/\\n/g, ' ');\n} catch (e) {\n readmeText = 'No README available';\n}\n\nconst commitMsg = commit?.commit?.message || 'No recent commits';\nconst commitAuthor = commit?.commit?.author?.name || 'Unknown';\n\nreturn [{\n json: {\n container: ctx.container,\n image: ctx.image,\n slug: ctx.slug,\n repo: ctx.repo,\n commitMsg,\n commitAuthor,\n readmeExcerpt: readmeText\n }\n}];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2192,
|
|
||||||
480
|
|
||||||
],
|
|
||||||
"id": "8810426d-c146-42c9-8ec2-5d8f56934a1f",
|
|
||||||
"name": "Merge Git Data"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "145931600",
|
|
||||||
"text": "={{ \n'🚀 Neuer Deploy: ' + $json.container + '\\n' +\n'📦 ' + $json.image + '\\n\\n' +\n'📝 Letzter Commit:\\n' + $json.commitMsg + '\\n' +\n'👤 ' + $json.commitAuthor + '\\n\\n' +\n'📄 README:\\n' + $json.readmeExcerpt + '...\\n\\n' +\n'Was ist das Highlight?' \n}}",
|
|
||||||
"replyMarkup": "inlineKeyboard",
|
|
||||||
"inlineKeyboard": {
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"row": {
|
|
||||||
"buttons": [
|
|
||||||
{
|
|
||||||
"text": "Selbst beschreiben",
|
|
||||||
"additionalFields": {
|
|
||||||
"callback_data": "={{ 'manual:' + $json.slug }}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text": "Auto-generieren",
|
|
||||||
"additionalFields": {
|
|
||||||
"callback_data": "={{ 'ignore:' + $json.slug }}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
2544,
|
|
||||||
592
|
|
||||||
],
|
|
||||||
"id": "d4016ea3-7233-4926-af21-c7b07cc5f39d",
|
|
||||||
"name": "Ask via Telegram",
|
|
||||||
"webhookId": "313376d7-33a6-4c80-938b-e8ebc7ee2d11",
|
|
||||||
"credentials": {
|
|
||||||
"telegramApi": {
|
|
||||||
"id": "ADurvy9EKUDzbDdq",
|
|
||||||
"name": "DK0_Server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"promptType": "define",
|
|
||||||
"text": "=Du bist ein technischer Autor für dk0.dev.\n\nNeuer Self-Hosted Service:\nContainer: {{ $('Parse Context').item.json.container }}\nImage: {{ $('Parse Context').item.json.image }}\n\nErstelle eine Portfolio-Beschreibung:\n- Was macht die App\n- Warum Self-Hosting besser ist als Cloud\n- Wie sie in die Infrastruktur integriert ist\n\nAntworte NUR als JSON:\n{\n \"title_en\": \"Titel\",\n \"title_de\": \"Titel\",\n \"description_en\": \"4-6 Sätze\",\n \"description_de\": \"4-6 Sätze\",\n \"content_en\": \"2-3 Absätze Markdown\",\n \"content_de\": \"2-3 Absätze Markdown\",\n \"category\": \"selfhosted\",\n \"technologies\": [\"Docker\", \"...\"]\n}",
|
|
||||||
"batching": {},
|
|
||||||
"prompt": "\n Verwende keine Bindestriche, Em-Dashes oder Gedankenstriche (–, —, -)."
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
|
||||||
"typeVersion": 1.9,
|
|
||||||
"position": [
|
|
||||||
1952,
|
|
||||||
864
|
|
||||||
],
|
|
||||||
"id": "0fd46a9d-40a9-4bb7-be5e-9b32b9a96381",
|
|
||||||
"name": "AI: Self-Hosted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"chatId": "145931600",
|
|
||||||
"text": "={{ \n'🆕 Self-Hosted Service: ' + $('Parse Context').first().json.serviceName + '\\n\\n' +\n'📝 ' + $json.data.title + '\\n\\n' +\n'Status: Draft erstellt (ID: ' + $json.data.id + ')\\n\\n' +\n'/publishproject' + $json.data.id + ' — Veröffentlichen\\n' + \n'/deleteproject' + $json.data.id + ' — Löschen' \n}}",
|
|
||||||
"additionalFields": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.telegram",
|
|
||||||
"typeVersion": 1.2,
|
|
||||||
"position": [
|
|
||||||
2656,
|
|
||||||
848
|
|
||||||
],
|
|
||||||
"id": "bfaca06b-65ca-41a8-ba8a-1b1aef7ba12d",
|
|
||||||
"name": "Notify Selfhosted",
|
|
||||||
"webhookId": "a7d15c96-41e1-4242-9b5f-0382f4f0d31a",
|
|
||||||
"credentials": {
|
|
||||||
"telegramApi": {
|
|
||||||
"id": "ADurvy9EKUDzbDdq",
|
|
||||||
"name": "DK0_Server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "{ \"success\": true, \"message\": \"CI/CD container ignored\" }",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
1776,
|
|
||||||
960
|
|
||||||
],
|
|
||||||
"id": "d93818d9-64f9-4f57-ae84-c4280eeb50f0",
|
|
||||||
"name": "Respond (Ignore)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "{ \"success\": true }",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
2880,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "4f1ad083-e73a-497c-a724-673205254b34",
|
|
||||||
"name": "Respond"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "{ \"success\": true, \"message\": \"Project already exists\" }",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
1568,
|
|
||||||
960
|
|
||||||
],
|
|
||||||
"id": "0b93b3c7-c158-4389-af18-b418aa3b2239",
|
|
||||||
"name": "Respond (Exists)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"path": "docker-event",
|
|
||||||
"responseMode": "responseNode",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2.1,
|
|
||||||
"position": [
|
|
||||||
688,
|
|
||||||
768
|
|
||||||
],
|
|
||||||
"id": "2b1c77d4-9f7f-4758-9e8e-f88195448ba3",
|
|
||||||
"name": "Webhook1",
|
|
||||||
"webhookId": "25d94042-2088-4e09-bfae-645db3d6803f"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"model": "openrouter/free",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
1968,
|
|
||||||
1072
|
|
||||||
],
|
|
||||||
"id": "a450227f-f1e5-44f3-a90e-044420042fc4",
|
|
||||||
"name": "OpenRouter Chat Model1",
|
|
||||||
"credentials": {
|
|
||||||
"openRouterApi": {
|
|
||||||
"id": "8Kdy4RHHwMZ0Cn6x",
|
|
||||||
"name": "OpenRouter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const raw = $input.first().json.text ?? \"\";\nconst match = raw.match(/\\{[\\s\\S]*\\}/);\nif (!match) throw new Error(\"No JSON found\");\nconst ai = JSON.parse(match[0]);\nreturn [{ json: ai }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2224,
|
|
||||||
848
|
|
||||||
],
|
|
||||||
"id": "ca78ecdd-5520-4540-969b-9e7b77bac3b4",
|
|
||||||
"name": "Parse JSON1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const ai = $input.first().json;\nconst ctx = $('Parse Context').first().json;\n\nconst body = {\n slug: ctx.slug,\n status: \"draft\",\n featured: false,\n title: ai.title_en,\n category: ai.category,\n technologies: ai.technologies,\n tags: ai.technologies,\n date: new Date().toISOString().slice(0, 10),\n translations: {\n create: [\n {\n languages_code: \"en-US\",\n title: ai.title_en,\n description: ai.description_en,\n content: ai.content_en\n },\n {\n languages_code: \"de-DE\",\n title: ai.title_de,\n description: ai.description_de,\n content: ai.content_de\n }\n ]\n }\n};\n\nconst response = await this.helpers.httpRequest({\n method: \"POST\",\n url: \"https://cms.dk0.dev/items/projects\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Authorization\": \"Bearer RF2QytqhcLXuVy6FO3PzWlsoR-ysCTwB\"\n },\n body\n});\n\nreturn [{ json: response }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2448,
|
|
||||||
848
|
|
||||||
],
|
|
||||||
"id": "1ac0a31c-68a1-44df-a6b3-203698318cbf",
|
|
||||||
"name": "Add to Directus1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"Webhook": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Kontext aufbereiten",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Kontext aufbereiten": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Search for Slug",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"If": {
|
|
||||||
"main": [
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Basic LLM Chain",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Search for Slug": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "If",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"OpenRouter Chat Model": {
|
|
||||||
"ai_languageModel": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Basic LLM Chain",
|
|
||||||
"type": "ai_languageModel",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Basic LLM Chain": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse JSON",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse JSON": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Switch",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Add to Directus": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Send a text message",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Send a text message": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond to Webhook",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Switch": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Add to Directus",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse Context": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Check if Exists",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Check if Exists": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "If New",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"If New": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Switch Type",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond (Exists)",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Switch Type": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get Last Commit",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond (Ignore)",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI: Self-Hosted",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Get Last Commit": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get README",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Get README": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Merge Git Data",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Merge Git Data": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Ask via Telegram",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Ask via Telegram": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AI: Self-Hosted": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse JSON1",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Notify Selfhosted": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Webhook1": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse Context",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"OpenRouter Chat Model1": {
|
|
||||||
"ai_languageModel": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI: Self-Hosted",
|
|
||||||
"type": "ai_languageModel",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse JSON1": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Add to Directus1",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Add to Directus1": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Notify Selfhosted",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": true,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate",
|
|
||||||
"availableInMCP": false
|
|
||||||
},
|
|
||||||
"versionId": "1e2cf0ca-fe15-4a10-9716-30f85a2c2531",
|
|
||||||
"meta": {
|
|
||||||
"templateCredsSetupCompleted": true,
|
|
||||||
"instanceId": "cb28e4db755465d5826da179e87f69603d81f833414cc52c327be9183a217b8d"
|
|
||||||
},
|
|
||||||
"id": "RARR6MAlJSHAmBp8",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Gitea Runner Status Check Script
|
|
||||||
# Prüft den Status des Gitea Runners
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${BLUE}║ Gitea Runner Status Check ║${NC}"
|
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check 1: systemd service
|
|
||||||
echo -e "${CYAN}[1/5] Checking systemd service...${NC}"
|
|
||||||
if systemctl list-units --type=service --all | grep -q "gitea-runner.service"; then
|
|
||||||
echo -e "${GREEN}✓ systemd service found${NC}"
|
|
||||||
systemctl status gitea-runner --no-pager -l || true
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ systemd service not found (runner might be running differently)${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check 2: Running processes
|
|
||||||
echo -e "${CYAN}[2/5] Checking for running runner processes...${NC}"
|
|
||||||
RUNNER_PROCESSES=$(ps aux | grep -E "(gitea|act_runner|woodpecker)" | grep -v grep || echo "")
|
|
||||||
if [ ! -z "$RUNNER_PROCESSES" ]; then
|
|
||||||
echo -e "${GREEN}✓ Found runner processes:${NC}"
|
|
||||||
echo "$RUNNER_PROCESSES" | while read line; do
|
|
||||||
echo " $line"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ No runner processes found${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check 3: Docker containers (if runner runs in Docker)
|
|
||||||
echo -e "${CYAN}[3/5] Checking for runner Docker containers...${NC}"
|
|
||||||
RUNNER_CONTAINERS=$(docker ps -a --filter "name=runner" --format "{{.Names}}\t{{.Status}}" 2>/dev/null || echo "")
|
|
||||||
if [ ! -z "$RUNNER_CONTAINERS" ]; then
|
|
||||||
echo -e "${GREEN}✓ Found runner containers:${NC}"
|
|
||||||
echo "$RUNNER_CONTAINERS" | while read line; do
|
|
||||||
echo " $line"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ No runner containers found${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check 4: Common runner directories
|
|
||||||
echo -e "${CYAN}[4/5] Checking common runner directories...${NC}"
|
|
||||||
RUNNER_DIRS=(
|
|
||||||
"/tmp/gitea-runner"
|
|
||||||
"/opt/gitea-runner"
|
|
||||||
"/home/*/gitea-runner"
|
|
||||||
"~/.gitea-runner"
|
|
||||||
"/usr/local/gitea-runner"
|
|
||||||
)
|
|
||||||
|
|
||||||
FOUND_DIRS=0
|
|
||||||
for dir in "${RUNNER_DIRS[@]}"; do
|
|
||||||
# Expand ~ and wildcards
|
|
||||||
EXPANDED_DIR=$(eval echo "$dir" 2>/dev/null || echo "")
|
|
||||||
if [ -d "$EXPANDED_DIR" ]; then
|
|
||||||
echo -e "${GREEN}✓ Found runner directory: $EXPANDED_DIR${NC}"
|
|
||||||
FOUND_DIRS=$((FOUND_DIRS + 1))
|
|
||||||
# Check for config files
|
|
||||||
if [ -f "$EXPANDED_DIR/.runner" ] || [ -f "$EXPANDED_DIR/config.yml" ]; then
|
|
||||||
echo " → Contains configuration files"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $FOUND_DIRS -eq 0 ]; then
|
|
||||||
echo -e "${YELLOW}⚠ No runner directories found in common locations${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check 5: Network connections (check if runner is connecting to Gitea)
|
|
||||||
echo -e "${CYAN}[5/5] Checking network connections to Gitea...${NC}"
|
|
||||||
GITEA_URL="${GITEA_URL:-https://git.dk0.dev}"
|
|
||||||
if command -v netstat >/dev/null 2>&1; then
|
|
||||||
CONNECTIONS=$(netstat -tn 2>/dev/null | grep -E "(git.dk0.dev|3000|3001)" || echo "")
|
|
||||||
elif command -v ss >/dev/null 2>&1; then
|
|
||||||
CONNECTIONS=$(ss -tn 2>/dev/null | grep -E "(git.dk0.dev|3000|3001)" || echo "")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$CONNECTIONS" ]; then
|
|
||||||
echo -e "${GREEN}✓ Found connections to Gitea:${NC}"
|
|
||||||
echo "$CONNECTIONS" | head -5
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ No active connections to Gitea found${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
||||||
echo -e "${BLUE}Summary:${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ ! -z "$RUNNER_PROCESSES" ] || [ ! -z "$RUNNER_CONTAINERS" ]; then
|
|
||||||
echo -e "${GREEN}✓ Runner appears to be running${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "To check runner status in Gitea:"
|
|
||||||
echo " 1. Go to: https://git.dk0.dev/denshooter/portfolio/settings/actions/runners"
|
|
||||||
echo " 2. Check if runner-01 shows as 'online' or 'idle'"
|
|
||||||
echo ""
|
|
||||||
echo "To view runner logs:"
|
|
||||||
if [ ! -z "$RUNNER_PROCESSES" ]; then
|
|
||||||
echo " - Check process logs or journalctl"
|
|
||||||
fi
|
|
||||||
if [ ! -z "$RUNNER_CONTAINERS" ]; then
|
|
||||||
echo " - docker logs <container-name>"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Runner does not appear to be running${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "To start the runner:"
|
|
||||||
echo " 1. Find where the runner binary is located"
|
|
||||||
echo " 2. Check Gitea for registration token"
|
|
||||||
echo " 3. Run: ./act_runner register --config config.yml"
|
|
||||||
echo " 4. Run: ./act_runner daemon --config config.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}For more information, check:${NC}"
|
|
||||||
echo " - Gitea Runner Docs: https://docs.gitea.com/usage/actions/act-runner"
|
|
||||||
echo " - Runner Status: https://git.dk0.dev/denshooter/portfolio/settings/actions/runners"
|
|
||||||
echo ""
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Simplified Gitea deployment script for testing
|
|
||||||
# This version doesn't require database dependencies
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
PROJECT_NAME="portfolio"
|
|
||||||
CONTAINER_NAME="portfolio-app-simple"
|
|
||||||
IMAGE_NAME="portfolio-app"
|
|
||||||
PORT=3000
|
|
||||||
BACKUP_PORT=3001
|
|
||||||
LOG_FILE="./logs/gitea-deploy-simple.log"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Logging function
|
|
||||||
log() {
|
|
||||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
error() {
|
|
||||||
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
success() {
|
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
warning() {
|
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if running as root (skip in CI environments)
|
|
||||||
if [[ $EUID -eq 0 ]] && [[ -z "$CI" ]]; then
|
|
||||||
error "This script should not be run as root (use CI=true to override)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if Docker is running
|
|
||||||
if ! docker info > /dev/null 2>&1; then
|
|
||||||
error "Docker is not running. Please start Docker and try again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if we're in the right directory
|
|
||||||
if [ ! -f "package.json" ] || [ ! -f "Dockerfile" ]; then
|
|
||||||
error "Please run this script from the project root directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "🚀 Starting simplified Gitea deployment for $PROJECT_NAME"
|
|
||||||
|
|
||||||
# Step 1: Build Application
|
|
||||||
log "🔨 Step 1: Building application..."
|
|
||||||
|
|
||||||
# Build Next.js application
|
|
||||||
log "📦 Building Next.js application..."
|
|
||||||
npm run build || {
|
|
||||||
error "Build failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
success "✅ Application built successfully"
|
|
||||||
|
|
||||||
# Step 2: Docker Operations
|
|
||||||
log "🐳 Step 2: Docker operations..."
|
|
||||||
|
|
||||||
# Build Docker image
|
|
||||||
log "🏗️ Building Docker image..."
|
|
||||||
docker build -t "$IMAGE_NAME:latest" . || {
|
|
||||||
error "Docker build failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tag with timestamp
|
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
||||||
docker tag "$IMAGE_NAME:latest" "$IMAGE_NAME:$TIMESTAMP"
|
|
||||||
|
|
||||||
success "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
# Step 3: Deployment
|
|
||||||
log "🚀 Step 3: Deploying application..."
|
|
||||||
|
|
||||||
# Export environment variables for docker-compose compatibility
|
|
||||||
log "📝 Exporting environment variables..."
|
|
||||||
export NODE_ENV=${NODE_ENV:-production}
|
|
||||||
export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dk0.dev}
|
|
||||||
export MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
|
||||||
export MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
|
||||||
export MY_PASSWORD="${MY_PASSWORD}"
|
|
||||||
export MY_INFO_PASSWORD="${MY_INFO_PASSWORD}"
|
|
||||||
export ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}"
|
|
||||||
export LOG_LEVEL=${LOG_LEVEL:-info}
|
|
||||||
export PORT=${PORT:-3000}
|
|
||||||
|
|
||||||
# Log which variables are set (without revealing secrets)
|
|
||||||
log "Environment variables configured:"
|
|
||||||
log " - NODE_ENV: ${NODE_ENV}"
|
|
||||||
log " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
|
|
||||||
log " - MY_EMAIL: ${MY_EMAIL}"
|
|
||||||
log " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
|
|
||||||
log " - MY_PASSWORD: [SET]"
|
|
||||||
log " - MY_INFO_PASSWORD: [SET]"
|
|
||||||
log " - ADMIN_BASIC_AUTH: [SET]"
|
|
||||||
log " - LOG_LEVEL: ${LOG_LEVEL}"
|
|
||||||
log " - PORT: ${PORT}"
|
|
||||||
|
|
||||||
# Check if container is running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
|
||||||
log "📦 Stopping existing container..."
|
|
||||||
docker stop "$CONTAINER_NAME" || true
|
|
||||||
docker rm "$CONTAINER_NAME" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if port is available
|
|
||||||
if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then
|
|
||||||
warning "Port $PORT is in use. Trying backup port $BACKUP_PORT"
|
|
||||||
DEPLOY_PORT=$BACKUP_PORT
|
|
||||||
else
|
|
||||||
DEPLOY_PORT=$PORT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start new container with minimal environment variables
|
|
||||||
log "🚀 Starting new container on port $DEPLOY_PORT..."
|
|
||||||
docker run -d \
|
|
||||||
--name "$CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p "$DEPLOY_PORT:3000" \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL=https://dk0.dev \
|
|
||||||
-e MY_EMAIL=contact@dk0.dev \
|
|
||||||
-e MY_INFO_EMAIL=info@dk0.dev \
|
|
||||||
-e MY_PASSWORD=test-password \
|
|
||||||
-e MY_INFO_PASSWORD=test-password \
|
|
||||||
-e ADMIN_BASIC_AUTH=admin:test123 \
|
|
||||||
-e LOG_LEVEL=info \
|
|
||||||
"$IMAGE_NAME:latest" || {
|
|
||||||
error "Failed to start container"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wait for container to be ready
|
|
||||||
log "⏳ Waiting for container to be ready..."
|
|
||||||
sleep 20
|
|
||||||
|
|
||||||
# Check if container is actually running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then
|
|
||||||
error "Container failed to start or crashed"
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
log "🏥 Performing health check..."
|
|
||||||
HEALTH_CHECK_TIMEOUT=180
|
|
||||||
HEALTH_CHECK_INTERVAL=5
|
|
||||||
ELAPSED=0
|
|
||||||
|
|
||||||
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
|
||||||
# Check if container is still running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then
|
|
||||||
error "Container stopped during health check"
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try health check endpoint
|
|
||||||
if curl -f "http://localhost:$DEPLOY_PORT/api/health" > /dev/null 2>&1; then
|
|
||||||
success "✅ Application is healthy!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep $HEALTH_CHECK_INTERVAL
|
|
||||||
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
|
||||||
echo -n "."
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
|
||||||
error "Health check timeout. Application may not be running properly."
|
|
||||||
log "Container status:"
|
|
||||||
docker inspect "$CONTAINER_NAME" --format='{{.State.Status}} - {{.State.Health.Status}}'
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=100
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 4: Verification
|
|
||||||
log "✅ Step 4: Verifying deployment..."
|
|
||||||
|
|
||||||
# Test main page
|
|
||||||
if curl -f "http://localhost:$DEPLOY_PORT/" > /dev/null 2>&1; then
|
|
||||||
success "✅ Main page is accessible"
|
|
||||||
else
|
|
||||||
error "❌ Main page is not accessible"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show container status
|
|
||||||
log "📊 Container status:"
|
|
||||||
docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
|
||||||
|
|
||||||
# Show resource usage
|
|
||||||
log "📈 Resource usage:"
|
|
||||||
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" "$CONTAINER_NAME"
|
|
||||||
|
|
||||||
# Final success message
|
|
||||||
success "🎉 Simplified Gitea deployment completed successfully!"
|
|
||||||
log "🌐 Application is available at: http://localhost:$DEPLOY_PORT"
|
|
||||||
log "🏥 Health check endpoint: http://localhost:$DEPLOY_PORT/api/health"
|
|
||||||
log "📊 Container name: $CONTAINER_NAME"
|
|
||||||
log "📝 Logs: docker logs $CONTAINER_NAME"
|
|
||||||
|
|
||||||
# Update deployment log
|
|
||||||
echo "$(date): Simplified Gitea deployment successful - Port: $DEPLOY_PORT - Image: $IMAGE_NAME:$TIMESTAMP" >> "$LOG_FILE"
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Gitea-specific deployment script
|
|
||||||
# Optimiert für lokalen Gitea Runner
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
PROJECT_NAME="portfolio"
|
|
||||||
CONTAINER_NAME="portfolio-app"
|
|
||||||
IMAGE_NAME="portfolio-app"
|
|
||||||
PORT=3000
|
|
||||||
BACKUP_PORT=3001
|
|
||||||
LOG_FILE="./logs/gitea-deploy.log"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Logging function
|
|
||||||
log() {
|
|
||||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
error() {
|
|
||||||
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
success() {
|
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
warning() {
|
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if running as root (skip in CI environments)
|
|
||||||
if [[ $EUID -eq 0 ]] && [[ -z "$CI" ]]; then
|
|
||||||
error "This script should not be run as root (use CI=true to override)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if Docker is running
|
|
||||||
if ! docker info > /dev/null 2>&1; then
|
|
||||||
error "Docker is not running. Please start Docker and try again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if we're in the right directory
|
|
||||||
if [ ! -f "package.json" ] || [ ! -f "Dockerfile" ]; then
|
|
||||||
error "Please run this script from the project root directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "🚀 Starting Gitea deployment for $PROJECT_NAME"
|
|
||||||
|
|
||||||
# Step 1: Code Quality Checks
|
|
||||||
log "📋 Step 1: Running code quality checks..."
|
|
||||||
|
|
||||||
# Run linting
|
|
||||||
log "🔍 Running ESLint..."
|
|
||||||
npm run lint || {
|
|
||||||
error "ESLint failed. Please fix the issues before deploying."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
log "🧪 Running tests..."
|
|
||||||
npm run test:production || {
|
|
||||||
error "Tests failed. Please fix the issues before deploying."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
success "✅ Code quality checks passed"
|
|
||||||
|
|
||||||
# Step 2: Build Application
|
|
||||||
log "🔨 Step 2: Building application..."
|
|
||||||
|
|
||||||
# Build Next.js application
|
|
||||||
log "📦 Building Next.js application..."
|
|
||||||
npm run build || {
|
|
||||||
error "Build failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
success "✅ Application built successfully"
|
|
||||||
|
|
||||||
# Step 3: Docker Operations
|
|
||||||
log "🐳 Step 3: Docker operations..."
|
|
||||||
|
|
||||||
# Build Docker image
|
|
||||||
log "🏗️ Building Docker image..."
|
|
||||||
docker build -t "$IMAGE_NAME:latest" . || {
|
|
||||||
error "Docker build failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tag with timestamp
|
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
||||||
docker tag "$IMAGE_NAME:latest" "$IMAGE_NAME:$TIMESTAMP"
|
|
||||||
|
|
||||||
success "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
# Step 4: Deployment
|
|
||||||
log "🚀 Step 4: Deploying application..."
|
|
||||||
|
|
||||||
# Export environment variables for docker-compose compatibility
|
|
||||||
log "📝 Exporting environment variables..."
|
|
||||||
export NODE_ENV=${NODE_ENV:-production}
|
|
||||||
export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dk0.dev}
|
|
||||||
export MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
|
||||||
export MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
|
||||||
export MY_PASSWORD="${MY_PASSWORD}"
|
|
||||||
export MY_INFO_PASSWORD="${MY_INFO_PASSWORD}"
|
|
||||||
export ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}"
|
|
||||||
export LOG_LEVEL=${LOG_LEVEL:-info}
|
|
||||||
export PORT=${PORT:-3000}
|
|
||||||
|
|
||||||
# Log which variables are set (without revealing secrets)
|
|
||||||
log "Environment variables configured:"
|
|
||||||
log " - NODE_ENV: ${NODE_ENV}"
|
|
||||||
log " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
|
|
||||||
log " - MY_EMAIL: ${MY_EMAIL}"
|
|
||||||
log " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
|
|
||||||
log " - MY_PASSWORD: [SET]"
|
|
||||||
log " - MY_INFO_PASSWORD: [SET]"
|
|
||||||
log " - ADMIN_BASIC_AUTH: [SET]"
|
|
||||||
log " - LOG_LEVEL: ${LOG_LEVEL}"
|
|
||||||
log " - PORT: ${PORT}"
|
|
||||||
|
|
||||||
# Check if container is running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
|
||||||
log "📦 Stopping existing container..."
|
|
||||||
docker stop "$CONTAINER_NAME" || true
|
|
||||||
docker rm "$CONTAINER_NAME" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if port is available
|
|
||||||
if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then
|
|
||||||
warning "Port $PORT is in use. Trying backup port $BACKUP_PORT"
|
|
||||||
DEPLOY_PORT=$BACKUP_PORT
|
|
||||||
else
|
|
||||||
DEPLOY_PORT=$PORT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start new container with environment variables
|
|
||||||
log "🚀 Starting new container on port $DEPLOY_PORT..."
|
|
||||||
docker run -d \
|
|
||||||
--name "$CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p "$DEPLOY_PORT:3000" \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL=https://dk0.dev \
|
|
||||||
-e MY_EMAIL=contact@dk0.dev \
|
|
||||||
-e MY_INFO_EMAIL=info@dk0.dev \
|
|
||||||
-e MY_PASSWORD="${MY_PASSWORD:-your-email-password}" \
|
|
||||||
-e MY_INFO_PASSWORD="${MY_INFO_PASSWORD:-your-info-email-password}" \
|
|
||||||
-e ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}" \
|
|
||||||
-e LOG_LEVEL=info \
|
|
||||||
"$IMAGE_NAME:latest" || {
|
|
||||||
error "Failed to start container"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wait for container to be ready
|
|
||||||
log "⏳ Waiting for container to be ready..."
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
# Check if container is actually running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then
|
|
||||||
error "Container failed to start or crashed"
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
log "🏥 Performing health check..."
|
|
||||||
HEALTH_CHECK_TIMEOUT=120
|
|
||||||
HEALTH_CHECK_INTERVAL=3
|
|
||||||
ELAPSED=0
|
|
||||||
|
|
||||||
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
|
||||||
# Check if container is still running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then
|
|
||||||
error "Container stopped during health check"
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try health check endpoint
|
|
||||||
if curl -f "http://localhost:$DEPLOY_PORT/api/health" > /dev/null 2>&1; then
|
|
||||||
success "✅ Application is healthy!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep $HEALTH_CHECK_INTERVAL
|
|
||||||
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
|
||||||
echo -n "."
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
|
||||||
error "Health check timeout. Application may not be running properly."
|
|
||||||
log "Container status:"
|
|
||||||
docker inspect "$CONTAINER_NAME" --format='{{.State.Status}} - {{.State.Health.Status}}'
|
|
||||||
log "Container logs:"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail=100
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 5: Verification
|
|
||||||
log "✅ Step 5: Verifying deployment..."
|
|
||||||
|
|
||||||
# Test main page
|
|
||||||
if curl -f "http://localhost:$DEPLOY_PORT/" > /dev/null 2>&1; then
|
|
||||||
success "✅ Main page is accessible"
|
|
||||||
else
|
|
||||||
error "❌ Main page is not accessible"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show container status
|
|
||||||
log "📊 Container status:"
|
|
||||||
docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
|
||||||
|
|
||||||
# Show resource usage
|
|
||||||
log "📈 Resource usage:"
|
|
||||||
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" "$CONTAINER_NAME"
|
|
||||||
|
|
||||||
# Step 6: Cleanup
|
|
||||||
log "🧹 Step 6: Cleaning up old images..."
|
|
||||||
|
|
||||||
# Remove old images (keep last 3 versions)
|
|
||||||
docker images "$IMAGE_NAME" --format "table {{.Tag}}\t{{.ID}}" | tail -n +2 | head -n -3 | awk '{print $2}' | xargs -r docker rmi || {
|
|
||||||
warning "No old images to remove"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clean up unused Docker resources
|
|
||||||
docker system prune -f --volumes || {
|
|
||||||
warning "Failed to clean up Docker resources"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Final success message
|
|
||||||
success "🎉 Gitea deployment completed successfully!"
|
|
||||||
log "🌐 Application is available at: http://localhost:$DEPLOY_PORT"
|
|
||||||
log "🏥 Health check endpoint: http://localhost:$DEPLOY_PORT/api/health"
|
|
||||||
log "📊 Container name: $CONTAINER_NAME"
|
|
||||||
log "📝 Logs: docker logs $CONTAINER_NAME"
|
|
||||||
|
|
||||||
# Update deployment log
|
|
||||||
echo "$(date): Gitea deployment successful - Port: $DEPLOY_PORT - Image: $IMAGE_NAME:$TIMESTAMP" >> "$LOG_FILE"
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Gitea Runner Setup Script
|
|
||||||
# Installiert und konfiguriert einen lokalen Gitea Runner
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
GITEA_URL="${GITEA_URL:-http://localhost:3000}"
|
|
||||||
RUNNER_NAME="${RUNNER_NAME:-portfolio-runner}"
|
|
||||||
RUNNER_LABELS="${RUNNER_LABELS:-ubuntu-latest,self-hosted,portfolio}"
|
|
||||||
RUNNER_WORK_DIR="${RUNNER_WORK_DIR:-/tmp/gitea-runner}"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Logging function
|
|
||||||
log() {
|
|
||||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
error() {
|
|
||||||
echo -e "${RED}[ERROR]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
success() {
|
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
warning() {
|
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if running as root (skip in CI environments)
|
|
||||||
if [[ $EUID -eq 0 ]] && [[ -z "$CI" ]]; then
|
|
||||||
error "This script should not be run as root (use CI=true to override)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "🚀 Setting up Gitea Runner for Portfolio"
|
|
||||||
|
|
||||||
# Check if Gitea URL is accessible
|
|
||||||
log "🔍 Checking Gitea server accessibility..."
|
|
||||||
if ! curl -f "$GITEA_URL" > /dev/null 2>&1; then
|
|
||||||
error "Cannot access Gitea server at $GITEA_URL"
|
|
||||||
error "Please make sure Gitea is running and accessible"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
success "✅ Gitea server is accessible"
|
|
||||||
|
|
||||||
# Create runner directory
|
|
||||||
log "📁 Creating runner directory..."
|
|
||||||
mkdir -p "$RUNNER_WORK_DIR"
|
|
||||||
cd "$RUNNER_WORK_DIR"
|
|
||||||
|
|
||||||
# Download Gitea Runner
|
|
||||||
log "📥 Downloading Gitea Runner..."
|
|
||||||
RUNNER_VERSION="latest"
|
|
||||||
RUNNER_ARCH="linux-amd64"
|
|
||||||
|
|
||||||
# Get latest version
|
|
||||||
if [ "$RUNNER_VERSION" = "latest" ]; then
|
|
||||||
RUNNER_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -o '"tag_name": "[^"]*' | grep -o '[^"]*$')
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUNNER_URL="https://github.com/woodpecker-ci/woodpecker/releases/download/${RUNNER_VERSION}/woodpecker-agent_${RUNNER_VERSION}_${RUNNER_ARCH}.tar.gz"
|
|
||||||
|
|
||||||
log "Downloading from: $RUNNER_URL"
|
|
||||||
curl -L -o woodpecker-agent.tar.gz "$RUNNER_URL"
|
|
||||||
|
|
||||||
# Extract runner
|
|
||||||
log "📦 Extracting Gitea Runner..."
|
|
||||||
tar -xzf woodpecker-agent.tar.gz
|
|
||||||
chmod +x woodpecker-agent
|
|
||||||
|
|
||||||
success "✅ Gitea Runner downloaded and extracted"
|
|
||||||
|
|
||||||
# Create systemd service
|
|
||||||
log "⚙️ Creating systemd service..."
|
|
||||||
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Runner for Portfolio
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$USER
|
|
||||||
WorkingDirectory=$RUNNER_WORK_DIR
|
|
||||||
ExecStart=$RUNNER_WORK_DIR/woodpecker-agent
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
Environment=WOODPECKER_SERVER=$GITEA_URL
|
|
||||||
Environment=WOODPECKER_AGENT_SECRET=
|
|
||||||
Environment=WOODPECKER_LOG_LEVEL=info
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Reload systemd
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
success "✅ Systemd service created"
|
|
||||||
|
|
||||||
# Instructions for manual registration
|
|
||||||
log "📋 Manual registration required:"
|
|
||||||
echo ""
|
|
||||||
echo "1. Go to your Gitea instance: $GITEA_URL"
|
|
||||||
echo "2. Navigate to: Settings → Actions → Runners"
|
|
||||||
echo "3. Click 'Create new Runner'"
|
|
||||||
echo "4. Copy the registration token"
|
|
||||||
echo "5. Run the following command:"
|
|
||||||
echo ""
|
|
||||||
echo " cd $RUNNER_WORK_DIR"
|
|
||||||
echo " ./woodpecker-agent register --server $GITEA_URL --token YOUR_TOKEN"
|
|
||||||
echo ""
|
|
||||||
echo "6. After registration, start the service:"
|
|
||||||
echo " sudo systemctl enable gitea-runner"
|
|
||||||
echo " sudo systemctl start gitea-runner"
|
|
||||||
echo ""
|
|
||||||
echo "7. Check status:"
|
|
||||||
echo " sudo systemctl status gitea-runner"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create helper scripts
|
|
||||||
log "📝 Creating helper scripts..."
|
|
||||||
|
|
||||||
# Start script
|
|
||||||
cat > "$RUNNER_WORK_DIR/start-runner.sh" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
echo "Starting Gitea Runner..."
|
|
||||||
sudo systemctl start gitea-runner
|
|
||||||
sudo systemctl status gitea-runner
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Stop script
|
|
||||||
cat > "$RUNNER_WORK_DIR/stop-runner.sh" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
echo "Stopping Gitea Runner..."
|
|
||||||
sudo systemctl stop gitea-runner
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Status script
|
|
||||||
cat > "$RUNNER_WORK_DIR/status-runner.sh" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
echo "Gitea Runner Status:"
|
|
||||||
sudo systemctl status gitea-runner
|
|
||||||
echo ""
|
|
||||||
echo "Logs (last 20 lines):"
|
|
||||||
sudo journalctl -u gitea-runner -n 20 --no-pager
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Logs script
|
|
||||||
cat > "$RUNNER_WORK_DIR/logs-runner.sh" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
echo "Gitea Runner Logs:"
|
|
||||||
sudo journalctl -u gitea-runner -f
|
|
||||||
EOF
|
|
||||||
|
|
||||||
chmod +x "$RUNNER_WORK_DIR"/*.sh
|
|
||||||
|
|
||||||
success "✅ Helper scripts created"
|
|
||||||
|
|
||||||
# Create environment file
|
|
||||||
cat > "$RUNNER_WORK_DIR/.env" << EOF
|
|
||||||
# Gitea Runner Configuration
|
|
||||||
GITEA_URL=$GITEA_URL
|
|
||||||
RUNNER_NAME=$RUNNER_NAME
|
|
||||||
RUNNER_LABELS=$RUNNER_LABELS
|
|
||||||
RUNNER_WORK_DIR=$RUNNER_WORK_DIR
|
|
||||||
EOF
|
|
||||||
|
|
||||||
log "📋 Setup Summary:"
|
|
||||||
echo " • Runner Directory: $RUNNER_WORK_DIR"
|
|
||||||
echo " • Gitea URL: $GITEA_URL"
|
|
||||||
echo " • Runner Name: $RUNNER_NAME"
|
|
||||||
echo " • Labels: $RUNNER_LABELS"
|
|
||||||
echo " • Helper Scripts: $RUNNER_WORK_DIR/*.sh"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
log "🎯 Next Steps:"
|
|
||||||
echo "1. Register the runner in Gitea web interface"
|
|
||||||
echo "2. Enable and start the service"
|
|
||||||
echo "3. Test with a workflow run"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
success "🎉 Gitea Runner setup completed!"
|
|
||||||
log "📁 All files are in: $RUNNER_WORK_DIR"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
|
||||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
|
||||||
|
|
||||||
async function setupSnippets() {
|
|
||||||
console.log('📦 Setting up Snippets collection...');
|
|
||||||
|
|
||||||
// 1. Create Collection
|
|
||||||
try {
|
|
||||||
await fetch(`${DIRECTUS_URL}/collections`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
collection: 'snippets',
|
|
||||||
meta: { icon: 'terminal', display_template: '{{title}}' },
|
|
||||||
schema: { name: 'snippets' }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (_e) {}
|
|
||||||
|
|
||||||
// 2. Add Fields
|
|
||||||
const fields = [
|
|
||||||
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown' }, schema: { default_value: 'published' } },
|
|
||||||
{ field: 'title', type: 'string', meta: { interface: 'input' } },
|
|
||||||
{ field: 'category', type: 'string', meta: { interface: 'input' } },
|
|
||||||
{ field: 'code', type: 'text', meta: { interface: 'input-code' } },
|
|
||||||
{ field: 'description', type: 'text', meta: { interface: 'textarea' } },
|
|
||||||
{ field: 'language', type: 'string', meta: { interface: 'input' }, schema: { default_value: 'javascript' } },
|
|
||||||
{ field: 'featured', type: 'boolean', meta: { interface: 'boolean' }, schema: { default_value: false } }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const f of fields) {
|
|
||||||
try {
|
|
||||||
await fetch(`${DIRECTUS_URL}/fields/snippets`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(f)
|
|
||||||
});
|
|
||||||
} catch (_e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Add Example Data
|
|
||||||
const exampleSnippets = [
|
|
||||||
{
|
|
||||||
title: 'Traefik SSL Config',
|
|
||||||
category: 'Docker',
|
|
||||||
language: 'yaml',
|
|
||||||
featured: true,
|
|
||||||
description: "Meine Standard-Konfiguration für automatisches SSL via Let's Encrypt in Docker Swarm.",
|
|
||||||
code: "labels:\n - \"traefik.enable=true\"\n - \"traefik.http.routers.myapp.rule=Host(`example.com`)\"\n - \"traefik.http.routers.myapp.entrypoints=websecure\"\n - \"traefik.http.routers.myapp.tls.certresolver=myresolver\""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Docker Cleanup Alias',
|
|
||||||
category: 'ZSH',
|
|
||||||
language: 'bash',
|
|
||||||
featured: true,
|
|
||||||
description: 'Ein einfacher Alias, um ungenutzte Docker-Container, Images und Volumes schnell zu entfernen.',
|
|
||||||
code: "alias dclean='docker system prune -af --volumes'"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of exampleSnippets) {
|
|
||||||
try {
|
|
||||||
await fetch(`${DIRECTUS_URL}/items/snippets`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(s)
|
|
||||||
});
|
|
||||||
} catch (_e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Snippets setup complete!');
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSnippets();
|
|
||||||
Reference in New Issue
Block a user