feat: implement admin knowledge extraction system with document upload and AI pipeline integration

This commit is contained in:
RaymondVerhoef
2026-05-10 11:18:48 +02:00
parent f8d0c68f84
commit 260644b41a
7 changed files with 625 additions and 5 deletions

View File

@@ -6,8 +6,9 @@ import { useApp } from './store/AppContext'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Admin from './pages/Admin'
// Placeholder components for routing structure // Placeholder components for routing structure
const Admin = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Admin</h1><p className="mt-4">Kennisbeheer and Source Upload.</p></div>
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div> const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
const Leren = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leren</h1><p className="mt-4">Start your weekly learning session.</p></div> const Leren = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leren</h1><p className="mt-4">Start your weekly learning session.</p></div>
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div> const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { storage } from '../../lib/storage';
const KnowledgeGraph = () => {
const svgRef = useRef(null);
const wrapperRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null);
// Load data
const topics = storage.get('kb:topics', []);
const relations = storage.get('kb:relations', []);
useEffect(() => {
if (!wrapperRef.current) return;
const { width, height } = wrapperRef.current.getBoundingClientRect();
setDimensions({ width, height });
const handleResize = () => {
if (wrapperRef.current) {
const { width, height } = wrapperRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions;
// Clear previous render
d3.select(svgRef.current).selectAll('*').remove();
// Setup nodes and links
// Copy objects because d3 modifies them
const nodes = topics.map(d => ({ ...d }));
const links = relations.map(d => ({ ...d }));
// Create SVG container with zoom support
const svg = d3.select(svgRef.current)
.attr('viewBox', [0, 0, width, height])
.attr('width', width)
.attr('height', height);
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Color scale for node types
const color = d3.scaleOrdinal()
.domain(['concept', 'role', 'process', 'fact'])
.range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']);
// Setup force simulation
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40));
// Draw links
const link = g.append('g')
.attr('stroke', 'var(--color-bg-warm)')
.attr('stroke-opacity', 0.6)
.selectAll('line')
.data(links)
.join('line')
.attr('stroke-width', 2);
// Draw nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.call(drag(simulation));
// Add circles to nodes
node.append('circle')
.attr('r', 20)
.attr('fill', d => color(d.type) || '#1F5560')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.on('click', (event, d) => {
setSelectedNode(d);
});
// Add labels to nodes
node.append('text')
.text(d => d.label)
.attr('x', 25)
.attr('y', 5)
.attr('font-size', '12px')
.attr('fill', 'var(--color-fg)')
.attr('class', 'font-mono select-none pointer-events-none');
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Drag behavior
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
return () => {
simulation.stop();
};
}, [dimensions, topics, relations]);
return (
<div className="relative w-full h-full flex flex-col md:flex-row">
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad.
</div>
) : (
<svg ref={svgRef} className="w-full h-full" />
)}
</div>
{/* Node Info Panel */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm">
<h3 className="font-bold text-lg mb-4 text-teal">Node Details</h3>
{selectedNode ? (
<div className="space-y-4">
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p>
<p className="font-medium">{selectedNode.label}</p>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
{selectedNode.type}
</span>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Beschrijving</p>
<p className="text-sm leading-relaxed">{selectedNode.description}</p>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">ID</p>
<p className="text-xs font-mono text-fg-muted break-all">{selectedNode.id}</p>
</div>
</div>
) : (
<p className="text-sm text-fg-muted">Klik op een node in de graaf om details te bekijken.</p>
)}
</div>
</div>
);
};
export default KnowledgeGraph;

View File

@@ -0,0 +1,137 @@
import React, { useState, useRef } from 'react';
import { UploadCloud, Link as LinkIcon, AlertCircle, CheckCircle } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
const UploadZone = ({ onUploadComplete }) => {
const [isDragging, setIsDragging] = useState(false);
const [url, setUrl] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [status, setStatus] = useState(null); // { type: 'error' | 'success', msg: string }
const fileInputRef = useRef(null);
const handleDragOver = (e) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const processFile = async (file) => {
setIsProcessing(true);
setStatus(null);
try {
const text = await file.text();
await processSourceText(text, file.name);
setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` });
if (onUploadComplete) onUploadComplete();
} catch (error) {
setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` });
} finally {
setIsProcessing(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
processFile(file);
} else {
setStatus({ type: 'error', msg: 'Alleen .txt en .md bestanden worden momenteel ondersteund.' });
}
}
};
const handleFileSelect = (e) => {
if (e.target.files && e.target.files.length > 0) {
processFile(e.target.files[0]);
}
};
const handleUrlSubmit = async (e) => {
e.preventDefault();
if (!url) return;
setIsProcessing(true);
setStatus(null);
try {
// In a real scenario, this would call a backend proxy to bypass CORS.
// For this prototype, we'll simulate fetching or only support text URLs.
setStatus({ type: 'error', msg: 'URL import is experimenteel en momenteel uitgeschakeld ivm CORS restricties in browser.' });
} finally {
setIsProcessing(false);
}
};
return (
<div className="space-y-6">
<Card
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 ${
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<UploadCloud size={48} className="text-teal/40 mb-4" />
<p className="font-medium text-lg">Sleep bestanden hierheen</p>
<p className="text-sm text-fg-muted mb-4">Ondersteunt .txt en .md (Max 5MB)</p>
<Button variant="outline" type="button" disabled={isProcessing}>
{isProcessing ? 'Bezig met AI extractie...' : 'Bladeren op apparaat'}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".txt,.md"
className="hidden"
/>
</Card>
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-bg-warm"></div>
<span className="text-sm text-fg-muted uppercase tracking-wider">OF</span>
<div className="flex-1 border-t border-bg-warm"></div>
</div>
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Importeer van URL"
placeholder="https://wiki.respellion.nl/artikel"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={isProcessing}
/>
</div>
<Button type="submit" disabled={isProcessing || !url}>
<LinkIcon size={18} className="mr-2" /> Import
</Button>
</form>
{status && (
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
status.type === 'error' ? 'bg-red-50 text-red-900 border border-red-200' : 'bg-teal-50 text-teal-900 border border-teal-200'
}`}>
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
<div>
<p className="font-medium">{status.type === 'error' ? 'Fout' : 'Succes'}</p>
<p className="text-sm">{status.msg}</p>
</div>
</div>
)}
</div>
);
};
export default UploadZone;

View File

@@ -1,3 +1,5 @@
import { storage } from './storage';
/** /**
* Anthropic API Service * Anthropic API Service
* Handles communication with the /v1/messages endpoint. * Handles communication with the /v1/messages endpoint.
@@ -16,7 +18,8 @@ export const anthropicApi = {
// In a real application, the API key should not be exposed to the client. // In a real application, the API key should not be exposed to the client.
// For this prototype, we'll assume it's passed or available in the environment, // For this prototype, we'll assume it's passed or available in the environment,
// or proxied via a secure endpoint if deployed. // or proxied via a secure endpoint if deployed.
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided'; // Prioritize key from local storage (set via Admin Settings)
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
while (retries <= maxRetries) { while (retries <= maxRetries) {
try { try {

View File

@@ -0,0 +1,112 @@
import { anthropicApi } from './api';
import { storage } from './storage';
const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie".
Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven.
Geef ALTIJD een valide JSON object terug in het volgende formaat:
{
"topics": [
{
"id": "unieke-slug",
"label": "Titel van onderwerp",
"type": "concept | role | process | fact",
"description": "Een beknopte, heldere uitleg van max 3 zinnen."
}
],
"relations": [
{
"source": "id-van-topic-1",
"target": "id-van-topic-2",
"type": "related_to | depends_on | part_of | executed_by"
}
]
}
Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`;
/**
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
* @param {string} textContent De te analyseren tekst.
* @param {string} sourceName Naam van de bron (voor tracking).
*/
export async function processSourceText(textContent, sourceName) {
// 1. Sla de bron eerst op als "processing"
const sourceId = `src_${Date.now()}`;
const newSource = {
id: sourceId,
name: sourceName,
status: 'processing',
date: new Date().toISOString()
};
const sources = storage.get('admin:sources', []);
storage.set('admin:sources', [newSource, ...sources]);
try {
// 2. Roep Anthropic API aan
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`);
// 3. Parse JSON (veilig)
let extractedData;
try {
// Zoek naar JSON in de response (indien het toch in markdown was verpakt)
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
} catch (e) {
throw new Error('AI response was geen geldige JSON.');
}
// 4. Deduplicatie en opslag van Topics en Relaties
mergeKnowledgeGraph(extractedData);
// 5. Markeer bron als voltooid
updateSourceStatus(sourceId, 'completed');
return { success: true, data: extractedData };
} catch (error) {
// Markeer bron als mislukt
updateSourceStatus(sourceId, 'failed', error.message);
throw error;
}
}
function updateSourceStatus(sourceId, status, errorMsg = '') {
const sources = storage.get('admin:sources', []);
const updated = sources.map(s =>
s.id === sourceId ? { ...s, status, error: errorMsg } : s
);
storage.set('admin:sources', updated);
}
function mergeKnowledgeGraph(newData) {
const existingTopics = storage.get('kb:topics', []);
const existingRelations = storage.get('kb:relations', []);
// Simpele deduplicatie op ID
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
if (newData.topics && Array.isArray(newData.topics)) {
for (const t of newData.topics) {
if (!topicsMap.has(t.id)) {
topicsMap.set(t.id, t);
}
}
}
// Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
const newRelations = [...existingRelations];
if (newData.relations && Array.isArray(newData.relations)) {
for (const r of newData.relations) {
// Simpele check om exacte duplicaten te voorkomen
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
if (!isDup) {
newRelations.push(r);
}
}
}
storage.set('kb:topics', Array.from(topicsMap.values()));
storage.set('kb:relations', newRelations);
}

View File

@@ -11,13 +11,13 @@ export const storage = {
* @param {string} key - e.g. 'kb:topics' * @param {string} key - e.g. 'kb:topics'
* @returns {any} * @returns {any}
*/ */
get(key) { get(key, defaultValue = null) {
try { try {
const item = window.localStorage.getItem(STORAGE_PREFIX + key); const item = window.localStorage.getItem(STORAGE_PREFIX + key);
return item ? JSON.parse(item) : null; return item ? JSON.parse(item) : defaultValue;
} catch (e) { } catch (e) {
console.error(`Error reading ${key} from storage:`, e); console.error(`Error reading ${key} from storage:`, e);
return null; return defaultValue;
} }
}, },

176
src/pages/Admin/index.jsx Normal file
View File

@@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react';
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save } from 'lucide-react';
import Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import { storage } from '../../lib/storage';
import UploadZone from '../../components/admin/UploadZone';
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
const Admin = () => {
const [activeTab, setActiveTab] = useState('bronnen');
const [sources, setSources] = useState([]);
const [apiKey, setApiKey] = useState('');
const [saveStatus, setSaveStatus] = useState(null);
const loadSources = () => {
setSources(storage.get('admin:sources', []));
};
useEffect(() => {
if (activeTab === 'bronnen') {
loadSources();
}
if (activeTab === 'instellingen') {
setApiKey(storage.get('admin:anthropic_key', ''));
}
}, [activeTab]);
const saveSettings = (e) => {
e.preventDefault();
storage.set('admin:anthropic_key', apiKey);
setSaveStatus('Opgeslagen!');
setTimeout(() => setSaveStatus(null), 3000);
};
return (
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
{/* Admin Sidebar */}
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Kennisbeheer</h2>
<button
onClick={() => setActiveTab('bronnen')}
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'bronnen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
>
<Database size={20} />
<span className="text-sm mt-1 md:mt-0">Bronnen</span>
</button>
<button
onClick={() => setActiveTab('graaf')}
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'graaf' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
>
<Network size={20} />
<span className="text-sm mt-1 md:mt-0">Graaf</span>
</button>
<button
onClick={() => setActiveTab('gebruikers')}
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'gebruikers' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
>
<Users size={20} />
<span className="text-sm mt-1 md:mt-0">Team</span>
</button>
<button
onClick={() => setActiveTab('instellingen')}
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 mt-0 md:mt-auto ${activeTab === 'instellingen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
>
<Settings size={20} />
<span className="text-sm mt-1 md:mt-0">Settings</span>
</button>
</div>
{/* Admin Main Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
{activeTab === 'bronnen' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Bronmateriaal</h1>
<p className="text-fg-muted mb-8">Upload bestanden of geef URL's op voor de AI-kennisextractie.</p>
<UploadZone onUploadComplete={loadSources} />
<h3 className="text-xl mb-4 mt-12">Recente Bronnen</h3>
<Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm">
{sources.length === 0 ? (
<div className="p-8 text-center text-fg-muted">Geen bronnen geüpload.</div>
) : (
sources.map((source) => (
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-full ${
source.status === 'completed' ? 'bg-teal-50 text-teal-600' :
source.status === 'processing' ? 'bg-purple-50 text-purple-600 animate-pulse' :
'bg-red-50 text-red-600'
}`}>
<FileText size={20} />
</div>
<div>
<p className="font-medium">{source.name}</p>
<p className="text-xs text-fg-muted flex items-center gap-1 mt-1">
<Clock size={12} /> {new Date(source.date).toLocaleString()}
</p>
</div>
</div>
<div>
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Voltooid</Tag>}
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Bezig</Tag>}
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Mislukt</Tag>}
</div>
</div>
))
)}
</div>
</Card>
</div>
)}
{activeTab === 'graaf' && (
<div className="animate-in fade-in duration-300 h-full flex flex-col">
<h1 className="text-3xl text-teal mb-2">Kennisgraaf</h1>
<p className="text-fg-muted mb-4">Visualisatie van de opgebouwde Respellion kennis.</p>
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
<KnowledgeGraph />
</Card>
</div>
)}
{activeTab === 'gebruikers' && (
<div className="animate-in fade-in duration-300">
<h1 className="text-3xl text-teal mb-2">Gebruikersbeheer</h1>
<Card className="border border-bg-warm">
<p className="text-fg-muted">Hier kunnen medewerkers worden toegevoegd en beheerd.</p>
</Card>
</div>
)}
{activeTab === 'instellingen' && (
<div className="animate-in fade-in duration-300 max-w-2xl">
<h1 className="text-3xl text-teal mb-2">Instellingen</h1>
<p className="text-fg-muted mb-8">Beheer applicatie-instellingen en API-sleutels.</p>
<Card className="border border-bg-warm">
<form onSubmit={saveSettings} className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">AI Configuratie</h3>
<Input
label="Anthropic API Key"
type="password"
placeholder="sk-ant-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-fg-muted mt-2">
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`) en wordt gebruikt voor alle AI-interacties.
</p>
</div>
<div className="flex items-center gap-4">
<Button type="submit">
<Save size={18} className="mr-2" /> Instellingen Opslaan
</Button>
{saveStatus && <span className="text-teal font-medium animate-in fade-out duration-1000 fill-mode-forwards">{saveStatus}</span>}
</div>
</form>
</Card>
</div>
)}
</div>
</div>
);
};
export default Admin;