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