Add 6 new tools: Hasher, Base64, JWT Decoder, Password Gen, Timestamp, Text Diff
- backend/tools/hasher.py: POST /api/hash/sha256 and /api/hash/bcrypt (bcrypt added to requirements) - backend/tools/base64tool.py: POST /api/base64/encode and /api/base64/decode - backend/tools/jwtdecoder.py: POST /api/jwt/decode (signature verification disabled) - backend/tools/passwordgen.py: POST /api/password/generate with charset and length options - backend/tools/timestamp.py: POST /api/timestamp/convert (unix<->date, ISO 8601 + German format) - backend/tools/textdiff.py: POST /api/text/diff returning structured added/removed/unchanged lines - All blueprints registered in app.py and tools/__init__.py - React components with copy button, dark/light mode support via CSS variables - ToolOverview rebuilt as card grid; App.jsx routes added for all tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function Base64Tool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const request = async (endpoint) => {
|
||||
setError('');
|
||||
setResult('');
|
||||
try {
|
||||
const res = await axios.post(endpoint, { text: input });
|
||||
setResult(res.data.result);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Base64 Encoder / Decoder</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); setError(''); }}
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
<button onClick={() => request('/api/base64/encode')}>Encode</button>
|
||||
<button onClick={() => request('/api/base64/decode')}>Decode</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Base64Tool;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function HasherTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [algo, setAlgo] = useState('sha256');
|
||||
const [result, setResult] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleHash = async () => {
|
||||
try {
|
||||
const res = await axios.post(`/api/hash/${algo}`, { text: input });
|
||||
setResult(res.data[algo]);
|
||||
} catch {
|
||||
alert('Fehler beim Hashen');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>SHA256 / bcrypt Hasher</h2>
|
||||
<p style={{ color: 'var(--accent)', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
|
||||
<strong>Hinweis:</strong> bcrypt ist sicher für Passwörter – SHA256 für Datenintegrität.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); }}
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
<select value={algo} onChange={(e) => { setAlgo(e.target.value); setResult(''); }} style={{ marginTop: '8px' }}>
|
||||
<option value="sha256">SHA256</option>
|
||||
<option value="bcrypt">bcrypt</option>
|
||||
</select>
|
||||
<button onClick={handleHash}>Hash berechnen</button>
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HasherTool;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const sectionBox = {
|
||||
marginTop: '12px',
|
||||
padding: '14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
};
|
||||
|
||||
function JwtDecoderTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [decoded, setDecoded] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDecode = async () => {
|
||||
setError('');
|
||||
setDecoded(null);
|
||||
try {
|
||||
const res = await axios.post('/api/jwt/decode', { token: input });
|
||||
setDecoded(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ungültiger JWT');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>JWT Decoder</h2>
|
||||
<p style={{ color: 'var(--accent)', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
|
||||
<strong>Hinweis:</strong> Dieser Decoder verifiziert keine Signatur – nur zur Analyse.
|
||||
</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setDecoded(null); setError(''); }}
|
||||
placeholder="JWT Token einfügen"
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<button onClick={handleDecode}>Dekodieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{decoded && (
|
||||
<>
|
||||
<div style={sectionBox}>
|
||||
<p className="eyebrow" style={{ marginBottom: '6px' }}>Header</p>
|
||||
<pre style={{ margin: 0, color: 'var(--text)', fontSize: '0.875rem', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(decoded.header, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={sectionBox}>
|
||||
<p className="eyebrow" style={{ marginBottom: '6px' }}>Payload</p>
|
||||
<pre style={{ margin: 0, color: 'var(--text)', fontSize: '0.875rem', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(decoded.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={{ ...sectionBox, borderColor: decoded.expired ? '#ef4444' : '#22c55e' }}>
|
||||
<p className="eyebrow" style={{ marginBottom: '4px' }}>Status</p>
|
||||
<span style={{ fontWeight: 700, color: decoded.expired ? '#ef4444' : '#22c55e' }}>
|
||||
{decoded.expired ? 'Abgelaufen' : 'Gültig'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JwtDecoderTool;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function getStrength(length, charsets) {
|
||||
const score = (charsets >= 3 ? 2 : charsets >= 2 ? 1 : 0) + (length >= 16 ? 2 : length >= 12 ? 1 : 0);
|
||||
if (score >= 4) return { label: 'Stark', color: '#22c55e' };
|
||||
if (score >= 2) return { label: 'Mittel', color: '#f59e0b' };
|
||||
return { label: 'Schwach', color: '#ef4444' };
|
||||
}
|
||||
|
||||
function PasswordGenTool() {
|
||||
const [length, setLength] = useState(16);
|
||||
const [uppercase, setUppercase] = useState(true);
|
||||
const [lowercase, setLowercase] = useState(true);
|
||||
const [numbers, setNumbers] = useState(true);
|
||||
const [symbols, setSymbols] = useState(false);
|
||||
const [result, setResult] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const charsets = [uppercase, lowercase, numbers, symbols].filter(Boolean).length;
|
||||
const strength = getStrength(length, charsets);
|
||||
|
||||
const generate = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const res = await axios.post('/api/password/generate', { length, uppercase, lowercase, numbers, symbols });
|
||||
setResult(res.data.password);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Generieren');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Passwort-Generator</h2>
|
||||
|
||||
<label style={{ display: 'block', color: 'var(--muted)', fontWeight: 600, marginBottom: '6px' }}>
|
||||
Länge: {length}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={8}
|
||||
max={64}
|
||||
value={length}
|
||||
onChange={(e) => setLength(Number(e.target.value))}
|
||||
style={{ accentColor: 'var(--accent)', padding: 0, border: 'none', background: 'transparent', marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', margin: '8px 0 12px' }}>
|
||||
{[
|
||||
{ label: 'Großbuchstaben', value: uppercase, set: setUppercase },
|
||||
{ label: 'Kleinbuchstaben', value: lowercase, set: setLowercase },
|
||||
{ label: 'Zahlen', value: numbers, set: setNumbers },
|
||||
{ label: 'Sonderzeichen', value: symbols, set: setSymbols },
|
||||
].map(({ label, value, set }) => (
|
||||
<label key={label} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', color: 'var(--text)', fontWeight: 500 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => set(e.target.checked)}
|
||||
style={{ width: 'auto', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '4px' }}>
|
||||
<span className="muted" style={{ fontSize: '0.875rem' }}>Stärke:</span>
|
||||
<span style={{ fontWeight: 700, color: strength.color }}>{strength.label}</span>
|
||||
</div>
|
||||
|
||||
<button onClick={generate}>Generieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.95rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordGenTool;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const diffColors = {
|
||||
added: { bg: 'rgba(34, 197, 94, 0.12)', text: '#22c55e', prefix: '+ ' },
|
||||
removed: { bg: 'rgba(239, 68, 68, 0.12)', text: '#ef4444', prefix: '- ' },
|
||||
unchanged: { bg: 'transparent', text: 'var(--muted)', prefix: ' ' },
|
||||
};
|
||||
|
||||
function TextDiffTool() {
|
||||
const [text1, setText1] = useState('');
|
||||
const [text2, setText2] = useState('');
|
||||
const [diff, setDiff] = useState(null);
|
||||
|
||||
const compare = async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/text/diff', { text1, text2 });
|
||||
setDiff(res.data.diff);
|
||||
} catch {
|
||||
alert('Fehler beim Vergleich');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Text Diff</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Text A</p>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={text1}
|
||||
onChange={(e) => { setText1(e.target.value); setDiff(null); }}
|
||||
placeholder="Originaltext eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Text B</p>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={text2}
|
||||
onChange={(e) => { setText2(e.target.value); setDiff(null); }}
|
||||
placeholder="Geänderter Text eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={compare}>Vergleichen</button>
|
||||
|
||||
{diff !== null && (
|
||||
<div style={{ marginTop: '16px', border: '1px solid var(--border)', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
{diff.length === 0 ? (
|
||||
<p style={{ padding: '12px 14px', color: 'var(--muted)', margin: 0 }}>Keine Unterschiede gefunden.</p>
|
||||
) : (
|
||||
diff.map((line, i) => {
|
||||
const c = diffColors[line.type] || diffColors.unchanged;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '2px 14px',
|
||||
background: c.bg,
|
||||
color: c.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
borderLeft: `3px solid ${line.type !== 'unchanged' ? c.text : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
{c.prefix}{line.text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextDiffTool;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function TimestampTool() {
|
||||
const [value, setValue] = useState('');
|
||||
const [direction, setDirection] = useState('unix_to_date');
|
||||
const [result, setResult] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [now, setNow] = useState(Math.floor(Date.now() / 1000));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const convert = async () => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await axios.post('/api/timestamp/convert', { value, direction });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ungültiger Wert');
|
||||
}
|
||||
};
|
||||
|
||||
const resultText = result
|
||||
? direction === 'unix_to_date' ? result.utc : String(result.unix)
|
||||
: '';
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(resultText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Timestamp Converter</h2>
|
||||
|
||||
<div style={{ marginBottom: '12px', padding: '10px 14px', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: '12px' }}>
|
||||
<span className="muted" style={{ fontSize: '0.875rem' }}>Aktueller Unix Timestamp: </span>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--accent)', fontWeight: 700 }}>{now}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
{[
|
||||
{ key: 'unix_to_date', label: 'Unix → Datum' },
|
||||
{ key: 'date_to_unix', label: 'Datum → Unix' },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={direction === key ? '' : 'ghost'}
|
||||
onClick={() => { setDirection(key); setResult(null); setError(''); }}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder={direction === 'unix_to_date' ? 'z.B. 1700000000' : 'z.B. 2024-01-15 oder 15.01.2024'}
|
||||
/>
|
||||
<button onClick={convert}>Konvertieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<div>
|
||||
{direction === 'unix_to_date' ? (
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)' }}>{result.utc}</span>
|
||||
) : (
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)' }}>{result.unix}</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimestampTool;
|
||||
@@ -2,9 +2,18 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const TOOLS = [
|
||||
{ icon: '🔒', path: '/tools/md5', title: 'MD5 Hasher', desc: 'MD5-Hash berechnen (nur zur Analyse)' },
|
||||
{ icon: '#️⃣', path: '/tools/hasher', title: 'SHA256 / bcrypt', desc: 'Sichere Hash-Algorithmen für Strings' },
|
||||
{ icon: '📦', path: '/tools/base64', title: 'Base64', desc: 'Text kodieren und dekodieren' },
|
||||
{ icon: '🔑', path: '/tools/jwt', title: 'JWT Decoder', desc: 'JWT Token analysieren (ohne Signaturprüfung)' },
|
||||
{ icon: '🔐', path: '/tools/password', title: 'Passwort-Generator', desc: 'Sichere Passwörter generieren' },
|
||||
{ icon: '🕐', path: '/tools/timestamp', title: 'Timestamp Converter', desc: 'Unix Timestamp in Datum umrechnen' },
|
||||
{ icon: '📝', path: '/tools/textdiff', title: 'Text Diff', desc: 'Zwei Texte vergleichen' },
|
||||
];
|
||||
|
||||
function ToolOverview() {
|
||||
const navigate = useNavigate();
|
||||
const role = localStorage.getItem('role');
|
||||
const [websites, setWebsites] = useState([]);
|
||||
const [loadingWebsites, setLoadingWebsites] = useState(true);
|
||||
|
||||
@@ -13,7 +22,7 @@ function ToolOverview() {
|
||||
try {
|
||||
const res = await axios.get('/api/websites');
|
||||
setWebsites(res.data);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setWebsites([]);
|
||||
} finally {
|
||||
setLoadingWebsites(false);
|
||||
@@ -27,9 +36,24 @@ function ToolOverview() {
|
||||
<h2>Tool-Übersicht</h2>
|
||||
<p>Wähle ein Tool aus:</p>
|
||||
|
||||
<button onClick={() => navigate('/tools/md5')}>🔒 MD5 Tool</button><br /><br />
|
||||
<div className="card-grid">
|
||||
{TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.path}
|
||||
className="link-card"
|
||||
onClick={() => navigate(tool.path)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="link-card__icon">{tool.icon}</div>
|
||||
<div>
|
||||
<div className="link-card__title">{tool.title}</div>
|
||||
<div className="link-card__desc">{tool.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: '24px' }}>🌐 Externe Webseiten</h3>
|
||||
<h3 style={{ marginTop: '24px' }}>Externe Webseiten</h3>
|
||||
{loadingWebsites ? (
|
||||
<p className="muted">Lade Links...</p>
|
||||
) : websites.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user