From 16b047e49f8f7cc20432f515e98e44569f8c66df Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Fri, 27 Feb 2026 10:32:44 +0100 Subject: [PATCH] feat: add IntranetServices page and services.json for external tools --- nest-intra/public/services.json | 27 +++ nest-intra/src/App.tsx | 2 + .../src/components/layout/IntranetLayout.tsx | 1 + .../src/pages/intranet/IntranetServices.tsx | 187 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 nest-intra/public/services.json create mode 100644 nest-intra/src/pages/intranet/IntranetServices.tsx diff --git a/nest-intra/public/services.json b/nest-intra/public/services.json new file mode 100644 index 0000000..e4339bf --- /dev/null +++ b/nest-intra/public/services.json @@ -0,0 +1,27 @@ +[ + { + "category": "Development", + "services": [ + { "name": "GitHub", "url": "https://github.com/CrowMate" }, + { "name": "Plane", "url": "https://plane.crowmate.fr/" }, + { "name": "Jenkins", "url": "https://jenkins.crowmate.fr/" }, + { "name": "Excalidraw", "url": "https://excalidraw.com/#room=dd1651be168b191eee57,LYP7GsVY6EKoyyp6vxWUGQ" } + ] + }, + { + "category": "Infrastructure", + "services": [ + { "name": "Proxmox", "url": "https://proxmox.devgoblin.me/" }, + { "name": "Cloudflare", "url": "https://cloudflare.com/" }, + { "name": "Coolify", "url": "https://deploy.crowmate.fr/" }, + { "name": "Grafana", "url": "https://grafana.crowmate.fr/" } + ] + }, + { + "category": "Communication", + "services": [ + { "name": "Google Drive", "url": "https://drive.google.com/drive/folders/1hegR6sCQ5a5BGfUgZKZu8MwBY7o7SeQb" }, + { "name": "Mail", "url": "https://zimbra1.mail.ovh.net/" } + ] + } +] diff --git a/nest-intra/src/App.tsx b/nest-intra/src/App.tsx index 066dacc..7714e48 100644 --- a/nest-intra/src/App.tsx +++ b/nest-intra/src/App.tsx @@ -14,6 +14,7 @@ const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed')) const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents')); const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers')); const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration')); +const IntranetServices = lazy(() => import('./pages/intranet/IntranetServices')); // ── App ──────────────────────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ export default function App() { } /> } /> } /> + } /> {/* Redirect root to intranet */} diff --git a/nest-intra/src/components/layout/IntranetLayout.tsx b/nest-intra/src/components/layout/IntranetLayout.tsx index 74fa19b..f9067bf 100644 --- a/nest-intra/src/components/layout/IntranetLayout.tsx +++ b/nest-intra/src/components/layout/IntranetLayout.tsx @@ -9,6 +9,7 @@ const INTRANET_LINKS = [ { to: '/intranet/events', label: 'Events', icon: '[E]', end: false }, { to: '/intranet/users', label: 'Users', icon: '[U]', end: false }, { to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false }, + { to: '/intranet/services', label: 'Services', icon: '[S]', end: false }, ]; export function IntranetLayout() { diff --git a/nest-intra/src/pages/intranet/IntranetServices.tsx b/nest-intra/src/pages/intranet/IntranetServices.tsx new file mode 100644 index 0000000..2960609 --- /dev/null +++ b/nest-intra/src/pages/intranet/IntranetServices.tsx @@ -0,0 +1,187 @@ +import { useState, useEffect } from 'react'; + +interface Service { + name: string; + url: string; +} + +interface ServiceCategory { + category: string; + services: Service[]; +} + +function getFaviconUrl(url: string): string { + try { + const domain = new URL(url).hostname; + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } catch { + return ''; + } +} + +function ServiceCard({ service }: { service: Service }) { + return ( + { + e.currentTarget.style.borderColor = 'var(--color-yellow)'; + e.currentTarget.style.background = 'rgba(37,99,235,0.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--color-border)'; + e.currentTarget.style.background = 'var(--color-surface)'; + }} + > + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+ {service.name} +
+
+ {service.url} +
+
+
+ ); +} + +export default function IntranetServices() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + fetch('/services.json') + .then((res) => { + if (!res.ok) throw new Error('Failed to load services.json'); + return res.json(); + }) + .then((data: ServiceCategory[]) => { + setCategories(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, []); + + const totalServices = categories.reduce((sum, cat) => sum + cat.services.length, 0); + + return ( +
+
+
+ INTRANET / SERVICES +
+

+ QUICK LINKS +

+

+ External services and tools. Edit public/services.json to update this list. +

+
+ + {loading && ( +
+ Loading services... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && categories.map((cat) => ( +
+
+ {cat.category} + + ({cat.services.length}) + +
+
+ {cat.services.map((service) => ( + + ))} +
+
+ ))} + + {!loading && !error && totalServices === 0 && ( +
+ No services configured. Add entries to public/services.json. +
+ )} +
+ ); +}