| |
|
|
| export function formatNumber(n) { |
| if (n == null) return '—'; |
| if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; |
| if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; |
| return String(n); |
| } |
|
|
| export function formatBytes(bytes) { |
| if (!bytes) return '0 B'; |
| const k = 1024; |
| const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; |
| } |
|
|
| export function formatDate(ts) { |
| if (!ts) return '—'; |
| return new Date(ts).toLocaleString(); |
| } |
|
|
| export function formatRelative(ts) { |
| if (!ts) return '—'; |
| const diff = Date.now() - new Date(ts).getTime(); |
| const s = Math.floor(diff / 1000); |
| if (s < 60) return 'just now'; |
| const m = Math.floor(s / 60); |
| if (m < 60) return `${m}m ago`; |
| const h = Math.floor(m / 60); |
| if (h < 24) return `${h}h ago`; |
| const d = Math.floor(h / 24); |
| return `${d}d ago`; |
| } |
|
|
| export function roleColor(role) { |
| return { admin: 'red', faculty: 'yellow', student: 'blue' }[role] ?? 'gray'; |
| } |
|
|
| export function tierColor(tier) { |
| return { |
| GPU_NVIDIA: 'green', |
| GPU_AMD: 'yellow', |
| CPU_ONLY: 'gray', |
| }[tier] ?? 'gray'; |
| } |
|
|
| export function modelTagColor(tag) { |
| return { |
| RECOMMENDED: 'green', |
| POSSIBLE: 'yellow', |
| NOT_RECOMMENDED: 'red', |
| CPU_ONLY: 'gray', |
| }[tag] ?? 'gray'; |
| } |
|
|
| |
| export function debounce(fn, ms = 300) { |
| let timer; |
| return (...args) => { |
| clearTimeout(timer); |
| timer = setTimeout(() => fn(...args), ms); |
| }; |
| } |
|
|
| |
| export async function copyToClipboard(text) { |
| await navigator.clipboard.writeText(text); |
| } |
|
|
| |
| export function renderMarkdown(text) { |
| if (!text) return ''; |
| let html = text |
| |
| .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => |
| `<pre><code class="language-${lang || 'text'}">${escapeHtml(code.trim())}</code></pre>`) |
| |
| .replace(/`([^`]+)`/g, (_, c) => `<code class="inline-code">${escapeHtml(c)}</code>`) |
| |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') |
| |
| .replace(/\*(.*?)\*/g, '<em>$1</em>') |
| |
| .replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-3 mb-1">$1</h3>') |
| .replace(/^## (.+)$/gm, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>') |
| .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>') |
| |
| .replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>') |
| .replace(/(<li.*<\/li>\n?)+/g, m => `<ul class="my-2 space-y-1">${m}</ul>`) |
| |
| .replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal">$1</li>') |
| |
| .replace(/\n\n/g, '</p><p class="mb-2">') |
| |
| .replace(/\n/g, '<br>'); |
|
|
| return `<p class="mb-2">${html}</p>`; |
| } |
|
|
| function escapeHtml(str) { |
| return str |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"'); |
| } |
|
|
| |
| export function buildHeatmap(dailyData) { |
| |
| const map = {}; |
| dailyData.forEach(d => { map[d.date] = d.count; }); |
|
|
| const today = new Date(); |
| const cells = []; |
| for (let i = 181; i >= 0; i--) { |
| const d = new Date(today); |
| d.setDate(d.getDate() - i); |
| const key = d.toISOString().slice(0, 10); |
| cells.push({ date: key, count: map[key] ?? 0 }); |
| } |
| return cells; |
| } |
|
|
| export function heatmapColor(count) { |
| if (!count) return 'var(--surface3)'; |
| if (count < 3) return 'rgba(217,116,73,0.25)'; |
| if (count < 8) return 'rgba(217,116,73,0.50)'; |
| if (count < 20) return 'rgba(217,116,73,0.75)'; |
| return 'var(--accent)'; |
| } |
|
|