Initial NUC Portal dashboard

- 17 internal services with live health status
- 28 external bookmarks organized by category
- Dark/light mode toggle with persistence
- Cmd+K search filtering
- Health API polling every 30 seconds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-01 22:52:38 +00:00
commit 1a7a0ed4d3
25 changed files with 7675 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { services } from '@/lib/services';
const NUC_HOST = '192.168.1.3';
// Check if a service is reachable by attempting a TCP connection
async function checkServiceHealth(port: number, timeout = 3000): Promise<'running' | 'stopped'> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(`http://${NUC_HOST}:${port}`, {
method: 'HEAD',
signal: controller.signal,
}).catch(() => null);
clearTimeout(timeoutId);
// If we get any response (even 404, 403, etc.), the service is running
return response ? 'running' : 'stopped';
} catch {
return 'stopped';
}
}
export async function GET() {
const healthStatus: Record<string, 'running' | 'stopped' | 'unknown'> = {};
// Check all services in parallel
const results = await Promise.allSettled(
services.map(async (service) => {
const status = await checkServiceHealth(service.port);
return { name: service.name, status };
})
);
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled') {
healthStatus[result.value.name] = result.value.status;
} else {
// If promise rejected, mark as unknown
healthStatus[(result.reason as { name: string })?.name] = 'unknown';
}
});
return NextResponse.json(healthStatus, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

99
src/app/globals.css Normal file
View File

@@ -0,0 +1,99 @@
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
/* Configure Tailwind v4 dark mode to use .dark class */
@custom-variant dark (&:is(.dark, .dark *));
:root {
--background: #F8FAFC;
--foreground: #1E293B;
/* Surface Colors */
--surface-page: #F8FAFC;
--surface-card: #FFFFFF;
--surface-muted: #F1F5F9;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface-page: var(--surface-page);
--color-surface-card: var(--surface-card);
--color-surface-muted: var(--surface-muted);
/* Fonts */
--font-sans: 'Inter', sans-serif;
}
/* Dark mode */
.dark {
--background: #0C0A09;
--foreground: #FAFAF9;
--surface-page: #0C0A09;
--surface-card: #1C1917;
--surface-muted: #292524;
}
body {
background: var(--background);
color: var(--foreground);
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar for dark mode */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: #1C1917;
}
.dark ::-webkit-scrollbar-thumb {
background: #44403C;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #57534E;
}
/* Line clamp utility */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Animation for loading states */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

24
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "NUC Portal",
description: "Self-hosted services dashboard for the NUC server",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className="antialiased">
<Providers>
{children}
</Providers>
</body>
</html>
);
}

109
src/app/page.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client';
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection } from '@/components';
import { usePortal } from '@/lib/PortalContext';
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services';
export default function Home() {
const { filteredServices, filteredBookmarks, healthStatus, searchQuery } = usePortal();
// Group services by category
const servicesByCategory = categoryOrder.reduce((acc, category) => {
const categoryServices = filteredServices.filter(s => s.category === category);
if (categoryServices.length > 0) {
acc[category] = categoryServices;
}
return acc;
}, {} as Record<ServiceCategory, typeof filteredServices>);
// Group bookmarks by category
const bookmarksByCategory = bookmarkCategoryOrder.reduce((acc, category) => {
const categoryBookmarks = filteredBookmarks.filter(b => b.category === category);
if (categoryBookmarks.length > 0) {
acc[category] = categoryBookmarks;
}
return acc;
}, {} as Record<BookmarkCategory, typeof filteredBookmarks>);
const hasServices = Object.keys(servicesByCategory).length > 0;
const hasBookmarks = Object.keys(bookmarksByCategory).length > 0;
const noResults = searchQuery && !hasServices && !hasBookmarks;
return (
<div className="min-h-screen bg-slate-50 dark:bg-stone-950">
<Header />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Search */}
<div className="mb-8 max-w-xl">
<SearchBar />
</div>
{/* No results message */}
{noResults && (
<div className="text-center py-12">
<p className="text-slate-500 dark:text-stone-500">
No results found for &quot;{searchQuery}&quot;
</p>
</div>
)}
{/* Services */}
{hasServices && (
<div className="mb-12">
<h2 className="text-xl font-bold text-slate-900 dark:text-stone-100 mb-6">
Services
</h2>
{Object.entries(servicesByCategory).map(([category, services]) => (
<CategorySection
key={category}
title={categoryLabels[category as ServiceCategory]}
count={services.length}
columns={3}
>
{services.map(service => (
<ServiceCard
key={service.name}
service={service}
status={healthStatus[service.name] || 'unknown'}
/>
))}
</CategorySection>
))}
</div>
)}
{/* Bookmarks */}
{hasBookmarks && (
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-stone-100 mb-6">
Bookmarks
</h2>
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => (
<CategorySection
key={category}
title={bookmarkCategoryLabels[category as BookmarkCategory]}
count={bookmarks.length}
columns={4}
>
{bookmarks.map(bookmark => (
<BookmarkCard
key={bookmark.name}
bookmark={bookmark}
/>
))}
</CategorySection>
))}
</div>
)}
</main>
{/* Footer */}
<footer className="text-center py-8 text-sm text-slate-400 dark:text-stone-600">
<span>NUC Portal</span>
<span className="mx-2"></span>
<span>192.168.1.3</span>
</footer>
</div>
);
}

7
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,7 @@
'use client';
import { PortalProvider } from '@/lib/PortalContext';
export function Providers({ children }: { children: React.ReactNode }) {
return <PortalProvider>{children}</PortalProvider>;
}

View File

@@ -0,0 +1,47 @@
'use client';
import { Bookmark } from '@/lib/services';
import { Icon } from './Icons';
interface BookmarkCardProps {
bookmark: Bookmark;
}
export function BookmarkCard({ bookmark }: BookmarkCardProps) {
return (
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 p-3 bg-white dark:bg-stone-900 rounded-lg border border-slate-200 dark:border-stone-800 hover:border-slate-300 dark:hover:border-stone-700 hover:shadow-md transition-all duration-200"
>
{/* Icon */}
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-md bg-slate-100 dark:bg-stone-800 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
<Icon
name={bookmark.icon}
size={16}
className="text-slate-500 dark:text-stone-400"
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-slate-900 dark:text-stone-100 truncate">
{bookmark.name}
</span>
<Icon
name="external-link"
size={12}
className="text-slate-400 dark:text-stone-600 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</div>
{bookmark.description && (
<p className="text-xs text-slate-500 dark:text-stone-500 truncate">
{bookmark.description}
</p>
)}
</div>
</a>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { ReactNode } from 'react';
interface CategorySectionProps {
title: string;
count: number;
children: ReactNode;
columns?: 2 | 3 | 4;
}
export function CategorySection({ title, count, children, columns = 3 }: CategorySectionProps) {
const gridCols = {
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
};
return (
<div className="mb-8">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100">
{title}
</h2>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-slate-200 dark:bg-stone-800 text-slate-600 dark:text-stone-400">
{count}
</span>
</div>
{/* Grid */}
<div className={`grid ${gridCols[columns]} gap-4`}>
{children}
</div>
</div>
);
}

59
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,59 @@
'use client';
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
export function Header() {
const { darkMode, setDarkMode, refreshHealth, isRefreshing } = usePortal();
return (
<header className="sticky top-0 z-50 bg-white/80 dark:bg-stone-950/80 backdrop-blur-sm border-b border-slate-200 dark:border-stone-800">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
{/* Logo / Title */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 flex items-center justify-center rounded-lg bg-gradient-to-br from-slate-700 to-slate-900 dark:from-stone-600 dark:to-stone-800">
<Icon name="server" size={18} className="text-white" />
</div>
<div>
<h1 className="font-bold text-lg text-slate-900 dark:text-stone-100">
NUC Portal
</h1>
<p className="text-xs text-slate-500 dark:text-stone-500">
192.168.1.3
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Refresh button */}
<button
onClick={refreshHealth}
disabled={isRefreshing}
className="p-2 rounded-lg bg-slate-100 dark:bg-stone-900 border border-slate-200 dark:border-stone-800 hover:bg-slate-200 dark:hover:bg-stone-800 disabled:opacity-50 transition-colors"
title="Refresh health status"
>
<Icon
name="refresh-cw"
size={18}
className={`text-slate-600 dark:text-stone-400 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
{/* Dark mode toggle */}
<button
onClick={() => setDarkMode(!darkMode)}
className="p-2 rounded-lg bg-slate-100 dark:bg-stone-900 border border-slate-200 dark:border-stone-800 hover:bg-slate-200 dark:hover:bg-stone-800 transition-colors"
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
<Icon
name={darkMode ? 'sun' : 'moon'}
size={18}
className="text-slate-600 dark:text-stone-400"
/>
</button>
</div>
</div>
</header>
);
}

118
src/components/Icons.tsx Normal file
View File

@@ -0,0 +1,118 @@
'use client';
// Simple SVG icon components based on Lucide icons
// Using inline SVGs to avoid external dependencies
interface IconProps {
className?: string;
size?: number;
}
const createIcon = (paths: string) => {
return function Icon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
dangerouslySetInnerHTML={{ __html: paths }}
/>
);
};
};
export const icons: Record<string, React.ComponentType<IconProps>> = {
// Infrastructure
'server': createIcon('<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>'),
'scroll-text': createIcon('<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/>'),
'monitor': createIcon('<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>'),
// Automation
'workflow': createIcon('<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>'),
// Development
'git-branch': createIcon('<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>'),
'database': createIcon('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>'),
'table': createIcon('<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>'),
// Knowledge
'book-open': createIcon('<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>'),
'grid-3x3': createIcon('<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/>'),
// Storage
'folder': createIcon('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
'hard-drive': createIcon('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
'archive': createIcon('<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>'),
// Monitoring
'activity': createIcon('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'),
'bell': createIcon('<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>'),
// Security
'lock': createIcon('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
'shield': createIcon('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>'),
// Developer tools
'book': createIcon('<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/>'),
'check-circle': createIcon('<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>'),
'brackets': createIcon('<path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/>'),
'package': createIcon('<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
'arrow-right-left': createIcon('<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>'),
// AI tools
'bot': createIcon('<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>'),
'message-square': createIcon('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
'search': createIcon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
'code': createIcon('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>'),
'terminal': createIcon('<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>'),
// AI platforms
'layout': createIcon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="9" y2="9"/><line x1="9" x2="9" y1="21" y2="9"/>'),
'cpu': createIcon('<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>'),
'smile': createIcon('<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>'),
'users': createIcon('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
// Utilities
'pencil': createIcon('<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>'),
'braces': createIcon('<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>'),
'image': createIcon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>'),
'image-down': createIcon('<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21"/><path d="m14 19.5 3 3v-6"/><path d="m17 22.5 3-3"/><circle cx="9" cy="9" r="2"/>'),
'file-image': createIcon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><circle cx="10" cy="12" r="2"/><path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'),
// Design
'figma': createIcon('<path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z"/><path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z"/><path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z"/><path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z"/><path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z"/>'),
'palette': createIcon('<circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>'),
'shapes': createIcon('<path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/>'),
'circle': createIcon('<circle cx="12" cy="12" r="10"/>'),
// Learning
'graduation-cap': createIcon('<path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/>'),
'globe': createIcon('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'),
// Productivity
'list-todo': createIcon('<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>'),
'notebook': createIcon('<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M16 2v20"/>'),
// UI elements
'sun': createIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>'),
'moon': createIcon('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'),
'external-link': createIcon('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'),
'refresh-cw': createIcon('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>'),
'x': createIcon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
'loader': createIcon('<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>'),
};
export function Icon({ name, className, size }: { name: string; className?: string; size?: number }) {
const IconComponent = icons[name];
if (!IconComponent) {
return <span className={className}>?</span>;
}
return <IconComponent className={className} size={size} />;
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
export function SearchBar() {
const { searchQuery, setSearchQuery } = usePortal();
const inputRef = useRef<HTMLInputElement>(null);
// Keyboard shortcut (Cmd+K or Ctrl+K)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
// Clear search on Escape
if (e.key === 'Escape' && searchQuery) {
setSearchQuery('');
inputRef.current?.blur();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [searchQuery, setSearchQuery]);
return (
<div className="relative">
<Icon
name="search"
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-stone-500"
/>
<input
ref={inputRef}
type="text"
placeholder="Search services and bookmarks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-16 py-2.5 bg-white dark:bg-stone-900 border border-slate-200 dark:border-stone-800 rounded-lg text-sm text-slate-900 dark:text-stone-100 placeholder-slate-400 dark:placeholder-stone-500 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-stone-600 focus:border-transparent transition-all"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{searchQuery ? (
<button
onClick={() => setSearchQuery('')}
className="p-1 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
>
<Icon name="x" size={14} className="text-slate-400 dark:text-stone-500" />
</button>
) : (
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-xs text-slate-400 dark:text-stone-600 bg-slate-100 dark:bg-stone-800 rounded border border-slate-200 dark:border-stone-700">
<span className="text-xs"></span>K
</kbd>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Service } from '@/lib/services';
import { HealthStatus } from '@/lib/PortalContext';
import { Icon } from './Icons';
interface ServiceCardProps {
service: Service;
status: HealthStatus;
}
const statusColors: Record<HealthStatus, string> = {
running: 'bg-emerald-500',
stopped: 'bg-red-500',
unknown: 'bg-slate-400 dark:bg-stone-500',
loading: 'bg-amber-500 animate-pulse',
};
const statusLabels: Record<HealthStatus, string> = {
running: 'Running',
stopped: 'Stopped',
unknown: 'Unknown',
loading: 'Checking...',
};
export function ServiceCard({ service, status }: ServiceCardProps) {
return (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="group relative block p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-800 hover:border-slate-300 dark:hover:border-stone-700 hover:shadow-lg transition-all duration-200"
>
{/* Status indicator */}
<div className="absolute top-3 right-3 flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
title={statusLabels[status]}
/>
</div>
{/* Icon */}
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
<Icon
name={service.icon}
size={20}
className="text-slate-600 dark:text-stone-400"
/>
</div>
{/* Content */}
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
{service.name}
</h3>
{service.description && (
<p className="text-sm text-slate-500 dark:text-stone-500 line-clamp-2">
{service.description}
</p>
)}
{/* Port badge */}
<div className="mt-3 text-xs text-slate-400 dark:text-stone-600 font-mono">
:{service.port}
</div>
</a>
);
}

7
src/components/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { Icon, icons } from './Icons';
export { ServiceCard } from './ServiceCard';
export { BookmarkCard } from './BookmarkCard';
export { CategorySection } from './CategorySection';
export { SearchBar } from './SearchBar';
export { Header } from './Header';
export { Section } from './ui/Section';

View File

@@ -0,0 +1,19 @@
'use client';
import { ReactNode } from 'react';
interface SectionProps {
title?: string;
description?: string;
children: ReactNode;
}
export function Section({ title, description, children }: SectionProps) {
return (
<section className="bg-white dark:bg-stone-900 rounded-2xl p-8 shadow-sm mb-6 border border-slate-100 dark:border-stone-800">
{title && <h2 className="text-xl font-semibold text-slate-900 dark:text-stone-50 mb-1">{title}</h2>}
{description && <p className="text-sm text-slate-600 dark:text-stone-400 mb-6">{description}</p>}
{children}
</section>
);
}

View File

@@ -0,0 +1 @@
export { Section } from './Section';

133
src/lib/PortalContext.tsx Normal file
View File

@@ -0,0 +1,133 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { services, bookmarks, Service, Bookmark } from './services';
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
interface HealthState {
[serviceName: string]: HealthStatus;
}
interface PortalContextType {
services: Service[];
bookmarks: Bookmark[];
healthStatus: HealthState;
darkMode: boolean;
setDarkMode: (dark: boolean) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredServices: Service[];
filteredBookmarks: Bookmark[];
refreshHealth: () => Promise<void>;
isRefreshing: boolean;
}
const PortalContext = createContext<PortalContextType | undefined>(undefined);
export function PortalProvider({ children }: { children: ReactNode }) {
const [darkMode, setDarkMode] = useState(true); // Default to dark mode
const [searchQuery, setSearchQuery] = useState('');
const [healthStatus, setHealthStatus] = useState<HealthState>(() => {
// Initialize all services as loading
const initial: HealthState = {};
services.forEach(s => {
initial[s.name] = 'loading';
});
return initial;
});
const [isRefreshing, setIsRefreshing] = useState(false);
// Apply dark mode to document
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
// Persist dark mode preference
useEffect(() => {
const saved = localStorage.getItem('portal-dark-mode');
if (saved !== null) {
setDarkMode(saved === 'true');
}
}, []);
useEffect(() => {
localStorage.setItem('portal-dark-mode', String(darkMode));
}, [darkMode]);
// Fetch health status
const refreshHealth = useCallback(async () => {
setIsRefreshing(true);
try {
const response = await fetch('/api/health');
if (response.ok) {
const data = await response.json();
setHealthStatus(data);
}
} catch (error) {
console.error('Failed to fetch health status:', error);
} finally {
setIsRefreshing(false);
}
}, []);
// Initial health fetch and periodic refresh
useEffect(() => {
refreshHealth();
const interval = setInterval(refreshHealth, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refreshHealth]);
// Filter services and bookmarks based on search query
const filteredServices = services.filter(service => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
service.name.toLowerCase().includes(query) ||
service.description?.toLowerCase().includes(query) ||
service.category.toLowerCase().includes(query)
);
});
const filteredBookmarks = bookmarks.filter(bookmark => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
bookmark.name.toLowerCase().includes(query) ||
bookmark.description?.toLowerCase().includes(query) ||
bookmark.category.toLowerCase().includes(query)
);
});
return (
<PortalContext.Provider
value={{
services,
bookmarks,
healthStatus,
darkMode,
setDarkMode,
searchQuery,
setSearchQuery,
filteredServices,
filteredBookmarks,
refreshHealth,
isRefreshing,
}}
>
{children}
</PortalContext.Provider>
);
}
export function usePortal() {
const context = useContext(PortalContext);
if (context === undefined) {
throw new Error('usePortal must be used within a PortalProvider');
}
return context;
}

137
src/lib/services.ts Normal file
View File

@@ -0,0 +1,137 @@
export type ServiceCategory = 'infrastructure' | 'development' | 'knowledge' | 'storage' | 'monitoring' | 'security' | 'automation';
export type BookmarkCategory = 'developer' | 'ai-tools' | 'ai-platforms' | 'utilities' | 'design' | 'learning' | 'productivity' | 'other';
export interface Service {
name: string;
url: string;
port: number;
icon: string;
category: ServiceCategory;
description?: string;
container?: string;
}
export interface Bookmark {
name: string;
url: string;
icon: string;
category: BookmarkCategory;
description?: string;
}
export const services: Service[] = [
// Infrastructure
{ name: 'Coolify', url: 'http://192.168.1.3:8000', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
{ name: 'Dozzle', url: 'http://192.168.1.3:9999', port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' },
{ name: 'Playwriter Browser', url: 'http://192.168.1.3:6081/vnc.html', port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' },
// Automation
{ name: 'n8n', url: 'http://192.168.1.3:5678', port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' },
// Development
{ name: 'Gitea', url: 'http://192.168.1.3:3030', port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' },
{ name: 'CloudBeaver', url: 'http://192.168.1.3:8978', port: 8978, icon: 'database', category: 'development', description: 'Database management UI' },
{ name: 'Adminer', url: 'http://192.168.1.3:8088', port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' },
// Knowledge
{ name: 'Outline', url: 'http://192.168.1.3:3080', port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' },
{ name: 'NocoDB', url: 'http://192.168.1.3:8084', port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' },
// Storage
{ name: 'FileBrowser', url: 'http://192.168.1.3:8085', port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' },
{ name: 'MinIO', url: 'http://192.168.1.3:9001', port: 9001, icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' },
{ name: 'Kopia', url: 'http://192.168.1.3:51515', port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' },
// Monitoring
{ name: 'Uptime Kuma', url: 'http://192.168.1.3:3001', port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' },
{ name: 'Ntfy', url: 'http://192.168.1.3:8333', port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' },
// Security
{ name: 'Vaultwarden', url: 'http://192.168.1.3:8222', port: 8222, icon: 'lock', category: 'security', description: 'Password manager' },
{ name: 'Authentik', url: 'http://192.168.1.3:9090', port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' },
];
export const bookmarks: Bookmark[] = [
// Developer Tools
{ name: 'DevDocs', url: 'https://devdocs.io', icon: 'book', category: 'developer', description: 'API documentation browser' },
{ name: 'Can I Use', url: 'https://caniuse.com', icon: 'check-circle', category: 'developer', description: 'Browser compatibility tables' },
{ name: 'Regex101', url: 'https://regex101.com', icon: 'brackets', category: 'developer', description: 'Regex tester & debugger' },
{ name: 'Bundlephobia', url: 'https://bundlephobia.com', icon: 'package', category: 'developer', description: 'NPM package size analyzer' },
{ name: 'Transform Tools', url: 'https://transform.tools', icon: 'arrow-right-left', category: 'developer', description: 'Code transformers' },
// AI Tools
{ name: 'Claude', url: 'https://claude.ai', icon: 'bot', category: 'ai-tools', description: 'Anthropic AI assistant' },
{ name: 'ChatGPT', url: 'https://chat.openai.com', icon: 'message-square', category: 'ai-tools', description: 'OpenAI chat assistant' },
{ name: 'Perplexity', url: 'https://perplexity.ai', icon: 'search', category: 'ai-tools', description: 'AI-powered search' },
{ name: 'Phind', url: 'https://phind.com', icon: 'code', category: 'ai-tools', description: 'AI for developers' },
{ name: 'Cursor', url: 'https://cursor.com', icon: 'terminal', category: 'ai-tools', description: 'AI-first code editor' },
// AI Platforms
{ name: 'v0', url: 'https://v0.dev', icon: 'layout', category: 'ai-platforms', description: 'Vercel AI UI generator' },
{ name: 'Replicate', url: 'https://replicate.com', icon: 'cpu', category: 'ai-platforms', description: 'ML model hosting' },
{ name: 'Hugging Face', url: 'https://huggingface.co', icon: 'smile', category: 'ai-platforms', description: 'ML models & datasets' },
{ name: 'Together AI', url: 'https://together.ai', icon: 'users', category: 'ai-platforms', description: 'Open model inference' },
// Utilities
{ name: 'Excalidraw', url: 'https://excalidraw.com', icon: 'pencil', category: 'utilities', description: 'Hand-drawn diagrams' },
{ name: 'JSON Crack', url: 'https://jsoncrack.com', icon: 'braces', category: 'utilities', description: 'JSON visualizer' },
{ name: 'Carbon', url: 'https://carbon.now.sh', icon: 'image', category: 'utilities', description: 'Code screenshot tool' },
{ name: 'Squoosh', url: 'https://squoosh.app', icon: 'image-down', category: 'utilities', description: 'Image compression' },
{ name: 'TinyPNG', url: 'https://tinypng.com', icon: 'file-image', category: 'utilities', description: 'PNG/JPEG compression' },
// Design
{ name: 'Figma', url: 'https://figma.com', icon: 'figma', category: 'design', description: 'Collaborative design tool' },
{ name: 'Coolors', url: 'https://coolors.co', icon: 'palette', category: 'design', description: 'Color palette generator' },
{ name: 'Heroicons', url: 'https://heroicons.com', icon: 'shapes', category: 'design', description: 'Beautiful hand-crafted icons' },
{ name: 'Lucide', url: 'https://lucide.dev', icon: 'circle', category: 'design', description: 'Icon library' },
// Learning
{ name: 'MDN Web Docs', url: 'https://developer.mozilla.org', icon: 'graduation-cap', category: 'learning', description: 'Web technology docs' },
{ name: 'web.dev', url: 'https://web.dev', icon: 'globe', category: 'learning', description: 'Modern web guidance' },
// Productivity
{ name: 'Linear', url: 'https://linear.app', icon: 'list-todo', category: 'productivity', description: 'Issue tracking' },
{ name: 'Notion', url: 'https://notion.so', icon: 'notebook', category: 'productivity', description: 'Notes & docs' },
];
export const categoryLabels: Record<ServiceCategory, string> = {
infrastructure: 'Infrastructure',
development: 'Development',
knowledge: 'Knowledge',
storage: 'Storage',
monitoring: 'Monitoring',
security: 'Security',
automation: 'Automation',
};
export const bookmarkCategoryLabels: Record<BookmarkCategory, string> = {
developer: 'Developer Tools',
'ai-tools': 'AI Tools',
'ai-platforms': 'AI Platforms',
utilities: 'Utilities',
design: 'Design',
learning: 'Learning',
productivity: 'Productivity',
other: 'Other',
};
export const categoryOrder: ServiceCategory[] = [
'infrastructure',
'automation',
'development',
'knowledge',
'storage',
'monitoring',
'security',
];
export const bookmarkCategoryOrder: BookmarkCategory[] = [
'developer',
'ai-tools',
'ai-platforms',
'utilities',
'design',
'learning',
'productivity',
'other',
];