Fleshed out the lab page and projects
136
app/lab/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use client";
|
||||||
|
import { LAB_SERVICES } from "@/data/lab";
|
||||||
|
import { ExternalLink, Lock, Box, Terminal, Globe } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
|
export default function LabPage() {
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="6xl">
|
||||||
|
<header className="mb-32">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Box className="text-blue-500" size={20} />
|
||||||
|
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase">
|
||||||
|
System_Lab
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-500 max-w-2xl leading-relaxed text-sm">
|
||||||
|
A registry of operational services and experimental R&D. Services
|
||||||
|
labeled
|
||||||
|
<span className="text-blue-500"> [VPN]</span> are secured via
|
||||||
|
Tailscale to maintain a hardened perimeter for sensitive telemetry.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-40 mb-20">
|
||||||
|
{" "}
|
||||||
|
{/* Increased spacing for alternating rhythm */}
|
||||||
|
{LAB_SERVICES.map((service, i) => {
|
||||||
|
const isEven = i % 2 === 0;
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
key={service.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} gap-12 md:gap-24 items-center group`}
|
||||||
|
>
|
||||||
|
{/* Image Side */}
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<div className="relative group aspect-[1.9/1] rounded-2xl overflow-hidden border border-neutral-800 bg-black shadow-2xl transition-colors hover:border-blue-500/50">
|
||||||
|
<Image
|
||||||
|
src={service.image}
|
||||||
|
alt={service.name}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-all duration-500 ease-out brightness-100 grayscale-[0.2] group-hover:grayscale-0 scale-100 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Side */}
|
||||||
|
<div className="w-full md:w-1/2 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center flex-wrap gap-3">
|
||||||
|
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{service.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Live Status Badge */}
|
||||||
|
{service.uptimeId && (
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
<Image
|
||||||
|
src={`https://status.georgew.dev/api/badge/${service.uptimeId}/status`}
|
||||||
|
alt="Online"
|
||||||
|
width={90}
|
||||||
|
height={20}
|
||||||
|
className="
|
||||||
|
h-4 w-auto
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
grayscale opacity-60
|
||||||
|
group-hover:grayscale-0 group-hover:opacity-100
|
||||||
|
"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{service.stack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="text-[10px] text-blue-500/70 uppercase tracking-widest font-bold"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-neutral-400 text-sm leading-relaxed max-w-md">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Conditional Actions */}
|
||||||
|
<div className="flex items-center gap-6 pt-4 border-t border-neutral-800/50">
|
||||||
|
{service.visibility === "public" && service.url ? (
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-2 text-[10px] text-white hover:text-blue-400 transition-colors uppercase font-bold tracking-widest"
|
||||||
|
>
|
||||||
|
<Globe size={14} /> Visit Service
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-neutral-600 uppercase font-bold tracking-widest cursor-default">
|
||||||
|
<Lock size={12} className="text-blue-500/50" /> VPN
|
||||||
|
Encrypted
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{service.gitUrl && (
|
||||||
|
<a
|
||||||
|
href={service.gitUrl}
|
||||||
|
target="_blank"
|
||||||
|
className="group/git flex items-center gap-2 text-[10px] text-neutral-500 hover:text-white transition-colors uppercase font-bold tracking-widest bg-neutral-900/50 px-3 py-1.5 rounded-lg border border-neutral-800 hover:border-neutral-700"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/forgejo.svg"
|
||||||
|
alt="Forgejo"
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className="opacity-50 group-hover/git:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
{`Source`}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/page.tsx
|
|
@ -126,15 +126,17 @@ export default function Home() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Top Row Right: The Service Registry */}
|
{/* Top Row Right: The Service Registry */}
|
||||||
<motion.div
|
<Link href="/lab" className="md:col-span-2 flex flex-col group">
|
||||||
variants={itemVariants}
|
<motion.div
|
||||||
whileHover={{ y: -5 }}
|
variants={itemVariants}
|
||||||
onMouseEnter={() => setIsHoveringMonitors(true)}
|
whileHover={{ y: -5 }}
|
||||||
onMouseLeave={() => setIsHoveringMonitors(false)}
|
onMouseEnter={() => setIsHoveringMonitors(true)}
|
||||||
className="group md:col-span-2 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px]"
|
onMouseLeave={() => setIsHoveringMonitors(false)}
|
||||||
>
|
className="flex-1 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px] hover:border-blue-500/30"
|
||||||
<MonitorCard isHovered={isHoveringMonitors} />
|
>
|
||||||
</motion.div>
|
<MonitorCard isHovered={isHoveringMonitors} />
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Project Category Cards */}
|
{/* Project Category Cards */}
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,42 @@ const MONITORS = [
|
||||||
{ id: 4, name: "Watchtower" },
|
{ id: 4, name: "Watchtower" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 6;
|
||||||
|
const INTERVAL_TIME = 2500;
|
||||||
|
|
||||||
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (isHovered) {
|
||||||
|
// Start rotating pages only when hovered
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setPage((p) => (p + 1) % totalPages);
|
||||||
|
}, INTERVAL_TIME);
|
||||||
|
} else {
|
||||||
|
// Defer state reset to avoid "cascading render" error
|
||||||
|
// and allow the fade-out animation to play smoothly
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isHovered, totalPages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
||||||
{/* DEFAULT VIEW: Always rendered, opacity controlled by parent hover */}
|
{/* --- DEFAULT VIEW --- */}
|
||||||
<div
|
<div
|
||||||
className={`transition-opacity duration-300 ${isHovered ? "opacity-0" : "opacity-100"}`}
|
className={`transition-opacity duration-300 ${
|
||||||
|
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -58,7 +88,7 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* REGISTRY VIEW: Only "Active" when hovered */}
|
{/* --- REGISTRY VIEW --- */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
|
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
|
||||||
isHovered
|
isHovered
|
||||||
|
|
@ -66,42 +96,61 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||||
: "opacity-0 translate-y-4 pointer-events-none"
|
: "opacity-0 translate-y-4 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] mb-3">
|
{/* HEADER WITH SYNCED TIMER */}
|
||||||
Service Registry
|
<div className="flex items-center justify-between mb-3 group/header">
|
||||||
</h4>
|
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] flex items-center gap-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
Explore Systems
|
||||||
|
<motion.span
|
||||||
|
animate={{ x: [0, 4, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</motion.span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-12 h-[2.5px] bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
key={`${isHovered}-${page}`}
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={isHovered ? { width: "100%" } : { width: "0%" }}
|
||||||
|
transition={{
|
||||||
|
duration: isHovered ? INTERVAL_TIME / 1000 : 0,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
className="h-full bg-blue-500/50" // Changed to blue to match Link intent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono text-neutral-600">
|
||||||
|
{String(page + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{/* We pass isHovered here to start/stop the timer inside a separate lifecycle */}
|
<RegistrySlider page={page} />
|
||||||
<RegistrySlider isHovered={isHovered} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RegistrySlider({ isHovered }: { isHovered: boolean }) {
|
function RegistrySlider({ page }: { page: number }) {
|
||||||
const [page, setPage] = useState(0);
|
const currentItems = MONITORS.slice(
|
||||||
const totalPages = Math.ceil(MONITORS.length / 6);
|
page * ITEMS_PER_PAGE,
|
||||||
|
(page + 1) * ITEMS_PER_PAGE,
|
||||||
useEffect(() => {
|
);
|
||||||
if (!isHovered) {
|
|
||||||
const timeout = setTimeout(() => setPage(0), 300);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setPage((p) => (p + 1) % totalPages);
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isHovered, totalPages]);
|
|
||||||
|
|
||||||
const currentItems = MONITORS.slice(page * 6, (page + 1) * 6);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={page} // Key change: this triggers the animation on page change
|
key={page}
|
||||||
initial={{ opacity: 0, x: 10 }}
|
initial={{ opacity: 0, x: 10 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -10 }}
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
|
@ -119,11 +168,11 @@ function RegistrySlider({ isHovered }: { isHovered: boolean }) {
|
||||||
<div className="flex gap-1 shrink-0 scale-75 origin-right">
|
<div className="flex gap-1 shrink-0 scale-75 origin-right">
|
||||||
<Image
|
<Image
|
||||||
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
|
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
|
||||||
width={60} // Set a base width
|
width={60}
|
||||||
height={20} // Set a base height
|
height={20}
|
||||||
className="h-5 w-auto" // Keep your CSS for sizing
|
className="h-5 w-auto"
|
||||||
alt="System Status"
|
alt="System Status"
|
||||||
unoptimized // Essential for live status badges
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
|
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
|
||||||
|
|
|
||||||
103
data/lab.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { LabService } from "@/types/index";
|
||||||
|
|
||||||
|
export const LAB_SERVICES: LabService[] = [
|
||||||
|
{
|
||||||
|
id: "observatory",
|
||||||
|
name: "The Observatory",
|
||||||
|
description:
|
||||||
|
"Astronomical API orchestration and orbital visualization. Built to track ISS transits and lunar phases over Copenhagen.",
|
||||||
|
stack: ["Next.js", "Node", "SQLite"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://observatory.georgew.dev",
|
||||||
|
gitUrl: "https://git.georgew.dev/george/observatory",
|
||||||
|
image: "/lab/observatory.jpg",
|
||||||
|
uptimeId: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "surf-hub",
|
||||||
|
name: "Surf Sentinel",
|
||||||
|
description:
|
||||||
|
"Custom telemetry dashboard for Llangennith Bay, Wales. Pulling real-time buoy data and wave height predictions to find the perfect window for a session.",
|
||||||
|
stack: ["Grafana", "Influx DB", "Node"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://surf.georgew.dev/d/adrx6b4/llangennith-beach-surf-data?orgId=1&from=now-24h&to=now&timezone=browser&refresh=1h&theme=dark&kiosk=true",
|
||||||
|
image: "/lab/surf-hub.jpg",
|
||||||
|
uptimeId: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audiobookshelf",
|
||||||
|
name: "The Archive",
|
||||||
|
description:
|
||||||
|
"Dedicated audiobook server for the household. Primarily used for our Brandon Sanderson, Patrick Rothfuss, and Dungeon Crawler Carl marathons.",
|
||||||
|
stack: ["Docker", "Compose", "Tailscale", "rclone"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://audio.georgew.dev",
|
||||||
|
image: "/lab/audiobookshelf.jpg",
|
||||||
|
uptimeId: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yamtrack",
|
||||||
|
name: "Yamtrack",
|
||||||
|
description:
|
||||||
|
"A specialized tracker for our anime watch-lists! Features custom metadata hooks to keep our seasonal progress in sync.",
|
||||||
|
stack: ["Docker", "Redis", "Tailscale"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://anime.georgew.dev",
|
||||||
|
image: "/lab/yamtrack.jpg",
|
||||||
|
uptimeId: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "paperless",
|
||||||
|
name: "Paperless-ngx",
|
||||||
|
description:
|
||||||
|
"Personal document management system with OCR and automated tagging. Digitizing our physical mail and records into a searchable, versioned archive.",
|
||||||
|
stack: ["Docker", "Redis", "PostgreSQL"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://paperless.georgew.dev",
|
||||||
|
image: "/lab/paperless.jpg",
|
||||||
|
uptimeId: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "change-detection",
|
||||||
|
name: "Signal Watcher",
|
||||||
|
description:
|
||||||
|
"Automated monitoring for the essentials: NASA news updates, hobby stock alerts, and Telegram pings the second a new episode of 'The Traitors' drops.",
|
||||||
|
stack: ["Telegram API", "Webhooks"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://alerts.georgew.dev",
|
||||||
|
image: "/lab/change-detection.jpg",
|
||||||
|
uptimeId: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops-suite",
|
||||||
|
name: "System Operations",
|
||||||
|
description:
|
||||||
|
"The 'Engine Room.' Utilizing Portainer for orchestration, Dozzle for log streaming, and Watchtower for automated container lifecycle management across the Hetzner node.",
|
||||||
|
stack: ["Portainer", "Dozzle", "Watchtower"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://portainer.georgew.dev",
|
||||||
|
image: "/lab/portainer.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wikijs",
|
||||||
|
name: "System Wiki",
|
||||||
|
description:
|
||||||
|
"The 'Source of Truth' for the home infrastructure. Contains deployment guides, network maps, and disaster recovery procedures for the entire node.",
|
||||||
|
stack: ["Wiki.js", "Markdown"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
url: "https://wiki.georgew.dev",
|
||||||
|
image: "/lab/wikijs.jpg",
|
||||||
|
uptimeId: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
name: "System Dashboard",
|
||||||
|
description:
|
||||||
|
"The central entry point for the GeorgeW ecosystem. A high-level overview providing unified access to all public and VPN-secured services.",
|
||||||
|
stack: ["Next.js", "Docker", "Reverse Proxy"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://dash.georgew.dev",
|
||||||
|
gitUrl: "https://git.georgew.dev/george/homepage",
|
||||||
|
image: "/lab/dashboard.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone'
|
output: "standalone",
|
||||||
|
// assetPrefix:
|
||||||
|
// process.env.NODE_ENV === "production"
|
||||||
|
// ? "https://cdn.georgew.dev"
|
||||||
|
// : undefined,
|
||||||
|
// images: {
|
||||||
|
// loader: "imgix",
|
||||||
|
// path: "https://cdn.georgew.dev/",
|
||||||
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
1
public/forgejo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="38.5 0.5 135 211"><style>.st3{fill:none;stroke:#d40000;stroke-width:15}</style><g transform="translate(6 6)"><path d="M58 168V70c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#f60;stroke-width:25"/><path d="M58 168v-30c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#d40000;stroke-width:25"/><circle cx="142" cy="20" r="18" style="fill:none;stroke:#f60;stroke-width:15"/><circle cx="142" cy="88" r="18" class="st3"/><circle cx="58" cy="180" r="18" class="st3"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
BIN
public/lab/audiobookshelf.jpg
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
public/lab/change-detection.jpg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
public/lab/observatory.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
public/lab/paperless.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/lab/portainer.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/lab/surf-hub.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/lab/wikijs.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/lab/yamtrack.jpg
Normal file
|
After Width: | Height: | Size: 392 KiB |
|
|
@ -35,3 +35,15 @@ export interface Project {
|
||||||
mermaidChart?: string;
|
mermaidChart?: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LabService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
stack: string[];
|
||||||
|
visibility: "public" | "tailscale";
|
||||||
|
url?: string;
|
||||||
|
gitUrl?: string;
|
||||||
|
image: string;
|
||||||
|
uptimeId?: number;
|
||||||
|
}
|
||||||
|
|
|
||||||