feat: add IntranetServices page and services.json for external tools
This commit is contained in:
27
nest-intra/public/services.json
Normal file
27
nest-intra/public/services.json
Normal file
@@ -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/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -14,6 +14,7 @@ const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'))
|
|||||||
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
|
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
|
||||||
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
|
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
|
||||||
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
|
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
|
||||||
|
const IntranetServices = lazy(() => import('./pages/intranet/IntranetServices'));
|
||||||
|
|
||||||
// ── App ────────────────────────────────────────────────────────────────────────
|
// ── App ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export default function App() {
|
|||||||
<Route path="events" element={<IntranetEvents />} />
|
<Route path="events" element={<IntranetEvents />} />
|
||||||
<Route path="users" element={<IntranetUsers />} />
|
<Route path="users" element={<IntranetUsers />} />
|
||||||
<Route path="moderation" element={<IntranetModeration />} />
|
<Route path="moderation" element={<IntranetModeration />} />
|
||||||
|
<Route path="services" element={<IntranetServices />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Redirect root to intranet */}
|
{/* Redirect root to intranet */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const INTRANET_LINKS = [
|
|||||||
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
|
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
|
||||||
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
|
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
|
||||||
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
|
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
|
||||||
|
{ to: '/intranet/services', label: 'Services', icon: '[S]', end: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function IntranetLayout() {
|
export function IntranetLayout() {
|
||||||
|
|||||||
187
nest-intra/src/pages/intranet/IntranetServices.tsx
Normal file
187
nest-intra/src/pages/intranet/IntranetServices.tsx
Normal file
@@ -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 (
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'border-color 0.2s, background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
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)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getFaviconUrl(service.url)}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
style={{ flexShrink: 0, borderRadius: '4px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1rem' }}>
|
||||||
|
{service.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{service.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntranetServices() {
|
||||||
|
const [categories, setCategories] = useState<ServiceCategory[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
INTRANET / SERVICES
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>
|
||||||
|
QUICK LINKS
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
|
||||||
|
External services and tools. Edit <code style={{ background: 'var(--color-bg-alt)', padding: '0.1rem 0.35rem', borderRadius: '3px', fontSize: '0.75rem' }}>public/services.json</code> to update this list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
|
||||||
|
Loading services...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-red)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'rgba(220,38,38,0.06)',
|
||||||
|
border: '1px solid rgba(220,38,38,0.2)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && categories.map((cat) => (
|
||||||
|
<div key={cat.category} style={{ marginBottom: '2rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--color-amber)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat.category}
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.5rem' }}>
|
||||||
|
({cat.services.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
|
||||||
|
{cat.services.map((service) => (
|
||||||
|
<ServiceCard key={service.url} service={service} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && !error && totalServices === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No services configured. Add entries to public/services.json.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user