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:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# NUC Portal
|
||||
|
||||
Self-hosted services dashboard for the NUC server at 192.168.1.3.
|
||||
|
||||
## Features
|
||||
|
||||
- Service cards with live health status indicators
|
||||
- Bookmark links to external developer tools
|
||||
- Dark/light mode toggle
|
||||
- Search filtering across services and bookmarks
|
||||
- Category-based organization
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed via Coolify with Nixpacks.
|
||||
|
||||
- FQDN: http://nuc.lan
|
||||
- Build: `npm run build`
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6538
package-lock.json
generated
Normal file
6538
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nuc-portal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted services dashboard for the NUC server",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
52
src/app/api/health/route.ts
Normal file
52
src/app/api/health/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
99
src/app/globals.css
Normal file
99
src/app/globals.css
Normal 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
24
src/app/layout.tsx
Normal 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
109
src/app/page.tsx
Normal 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 "{searchQuery}"
|
||||
</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
7
src/app/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { PortalProvider } from '@/lib/PortalContext';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <PortalProvider>{children}</PortalProvider>;
|
||||
}
|
||||
47
src/components/BookmarkCard.tsx
Normal file
47
src/components/BookmarkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/CategorySection.tsx
Normal file
37
src/components/CategorySection.tsx
Normal 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
59
src/components/Header.tsx
Normal 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
118
src/components/Icons.tsx
Normal 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} />;
|
||||
}
|
||||
60
src/components/SearchBar.tsx
Normal file
60
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/ServiceCard.tsx
Normal file
67
src/components/ServiceCard.tsx
Normal 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
7
src/components/index.ts
Normal 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';
|
||||
19
src/components/ui/Section.tsx
Normal file
19
src/components/ui/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/ui/index.ts
Normal file
1
src/components/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Section } from './Section';
|
||||
133
src/lib/PortalContext.tsx
Normal file
133
src/lib/PortalContext.tsx
Normal 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
137
src/lib/services.ts
Normal 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',
|
||||
];
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user