Initial commit
This commit is contained in:
367
src/components/LinkDashboard.tsx
Normal file
367
src/components/LinkDashboard.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { QuickLink, User } from '../types';
|
||||
import {
|
||||
LinkIcon, Plus, ExternalLink, Pencil, Trash2, Save, X,
|
||||
Search, Globe, FolderOpen, Star
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LinkDashboardProps {
|
||||
links: QuickLink[];
|
||||
currentUser: User;
|
||||
onAddLink: (link: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => void;
|
||||
onUpdateLink: (link: QuickLink) => void;
|
||||
onDeleteLink: (id: string) => void;
|
||||
}
|
||||
|
||||
// Accent palette - keys are stored in the DB so they survive reloads.
|
||||
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
|
||||
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
|
||||
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
|
||||
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
|
||||
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
|
||||
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
|
||||
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
|
||||
};
|
||||
const ACCENT_KEYS = Object.keys(ACCENTS);
|
||||
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try { return new URL(url).host; } catch { return url.replace(/^https?:\/\//, '').split('/')[0]; }
|
||||
}
|
||||
|
||||
type Draft = { title: string; url: string; description: string; category: string; color: string };
|
||||
const EMPTY_DRAFT: Draft = { title: '', url: '', description: '', category: '', color: 'emerald' };
|
||||
|
||||
export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateLink, onDeleteLink }: LinkDashboardProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
links.forEach(l => { if (l.category?.trim()) set.add(l.category.trim()); });
|
||||
return Array.from(set).sort();
|
||||
}, [links]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return links.filter(l => {
|
||||
const matchesSearch = !q ||
|
||||
l.title.toLowerCase().includes(q) ||
|
||||
l.description.toLowerCase().includes(q) ||
|
||||
l.url.toLowerCase().includes(q) ||
|
||||
l.category.toLowerCase().includes(q);
|
||||
const matchesCat = activeCategory === 'all'
|
||||
|| (activeCategory === '__uncat' ? !l.category?.trim() : l.category === activeCategory);
|
||||
return matchesSearch && matchesCat;
|
||||
});
|
||||
}, [links, search, activeCategory]);
|
||||
|
||||
// Group filtered links by category for a tidy board layout
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, QuickLink[]>();
|
||||
filtered.forEach(l => {
|
||||
const key = l.category?.trim() || 'Uncategorized';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(l);
|
||||
});
|
||||
return Array.from(map.entries()).sort((a, b) => {
|
||||
if (a[0] === 'Uncategorized') return 1;
|
||||
if (b[0] === 'Uncategorized') return -1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setDraft({ ...EMPTY_DRAFT, category: activeCategory !== 'all' && activeCategory !== '__uncat' ? activeCategory : '' });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (link: QuickLink) => {
|
||||
setEditingId(link.id);
|
||||
setDraft({ title: link.title, url: link.url, description: link.description, category: link.category, color: link.color });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = draft.title.trim();
|
||||
let url = draft.url.trim();
|
||||
if (!title || !url) return;
|
||||
// Be forgiving - assume https:// if no scheme was typed.
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
url,
|
||||
description: draft.description.trim(),
|
||||
category: draft.category.trim(),
|
||||
color: draft.color,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
const original = links.find(l => l.id === editingId);
|
||||
if (original) onUpdateLink({ ...original, ...payload });
|
||||
} else {
|
||||
onAddLink(payload);
|
||||
}
|
||||
closeForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-sans" id="link-dashboard-root">
|
||||
|
||||
{/* Header banner */}
|
||||
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
|
||||
LINKS
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||
<LinkIcon className="w-6 h-6 text-emerald-400" />
|
||||
Tooling & Quick Links
|
||||
</h2>
|
||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
||||
The team's bookmark bar that doesn't live in one person's browser. CheckMK, Semaphore, Grafana, that one wiki page nobody can ever find again. Stored in SQLite, shared with everyone.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAdd}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
|
||||
id="btn-add-link">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: search + category filter */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-[#1E293B] border border-slate-800 rounded-xl">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links by name, host, category…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveCategory('all')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400' : 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === cat ? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400' : 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{links.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
|
||||
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
|
||||
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Drop in the daily-driver tools - monitoring, automation, ticketing - so nobody has to ask "what's the URL for CheckMK again?" for the 40th time.
|
||||
</p>
|
||||
<button
|
||||
onClick={openAdd}
|
||||
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add your first link
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{grouped.map(([category, items]) => (
|
||||
<section key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FolderOpen className="w-4 h-4 text-slate-500" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
|
||||
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
|
||||
<div className="flex-1 h-px bg-slate-850 ml-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{items.map(link => {
|
||||
const a = accent(link.color);
|
||||
return (
|
||||
<div
|
||||
key={link.id}
|
||||
className={`group relative bg-[#1E293B] border border-slate-800 rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
|
||||
>
|
||||
<span className={`absolute top-0 left-0 bottom-0 w-1 rounded-l-xl ${a.bar} opacity-70`} />
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-slate-800 flex items-center justify-center shrink-0 overflow-hidden`}>
|
||||
<Globe className={`w-5 h-5 ${a.text}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
|
||||
title={link.title}
|
||||
>
|
||||
<span className="truncate">{link.title}</span>
|
||||
<ExternalLink className={`w-3.5 h-3.5 shrink-0 ${a.text}`} />
|
||||
</a>
|
||||
<p className={`text-[10px] font-mono ${a.text} truncate mt-0.5`}>{hostOf(link.url)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{link.description && (
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => openEdit(link)}
|
||||
title="Edit link"
|
||||
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-emerald-400 hover:border-emerald-600 transition-all hover:cursor-pointer"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
|
||||
}}
|
||||
title="Delete link"
|
||||
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-rose-400 hover:border-rose-600 transition-all hover:cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-150" onClick={closeForm}>
|
||||
<div
|
||||
className="w-full max-w-md bg-[#1E293B] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-emerald-400" />
|
||||
{editingId ? 'Edit Link' : 'New Quick Link'}
|
||||
</h3>
|
||||
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
|
||||
<input
|
||||
required autoFocus
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
placeholder="e.g. CheckMK Monitoring"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
|
||||
<input
|
||||
required
|
||||
value={draft.url}
|
||||
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
||||
placeholder="https://checkmk.internal"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1">Category</label>
|
||||
<input
|
||||
list="link-categories"
|
||||
value={draft.category}
|
||||
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
||||
placeholder="e.g. Monitoring, Automation, Docs"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||
/>
|
||||
<datalist id="link-categories">
|
||||
{categories.map(c => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
placeholder="What is this tool for?"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
|
||||
<div className="flex gap-2">
|
||||
{ACCENT_KEYS.map(key => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
onClick={() => setDraft({ ...draft, color: key })}
|
||||
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-[#1E293B] ring-white scale-110' : 'opacity-70 hover:opacity-100'}`}
|
||||
title={key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="button" onClick={closeForm} className="flex-1 py-2 bg-slate-900 border border-slate-800 text-slate-300 hover:text-white rounded font-semibold transition-colors hover:cursor-pointer">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded flex items-center justify-center gap-1.5 transition-colors hover:cursor-pointer">
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{editingId ? 'Save Changes' : 'Add Link'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user