import { useEffect, useState } from 'react';
export function useNumberTicker(value: number, duration = 600): number {
const [display, setDisplay] = useState(0);
useEffect(() => {
if (typeof window === 'undefined') {
setDisplay(value);
return;
}
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReduced || value === 0) {
setDisplay(value);
return;
}
const start = performance.now();
const startValue = display;
const delta = value - startValue;
let frame = 0;
const step = (now: number) => {
const elapsed = now - start;
const t = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
setDisplay(Math.round(startValue + delta * eased));
if (t < 1) frame = requestAnimationFrame(step);
else setDisplay(value);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [value, duration]);
return display;
}
import { useEffect, useState, useCallback } from 'react';
export type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'app-theme';
function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return theme;
}
function applyTheme(resolved: 'light' | 'dark') {
const root = document.documentElement;
root.classList.toggle('dark', resolved === 'dark');
root.style.colorScheme = resolved;
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return (stored === 'light' || stored === 'dark' || stored === 'system')
? stored
: 'system';
});
useEffect(() => {
applyTheme(resolveTheme(theme));
}, [theme]);
// Reage a mudança do SO quando theme === 'system'
useEffect(() => {
if (theme !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme(mql.matches ? 'dark' : 'light');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
localStorage.setItem(STORAGE_KEY, next);
setThemeState(next);
}, []);
return { theme, setTheme, resolvedTheme: resolveTheme(theme) };
}
const TOKEN_KEY = 'app:token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function installFetchInterceptor() {
if (typeof window === 'undefined') return;
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = typeof input === 'string'
? input
: input instanceof Request ? input.url : input.toString();
const isApi = url.includes('/api/');
const isAuthEndpoint = url.endsWith('/api/auth/login')
|| url.endsWith('/api/auth/register');
if (isApi) {
const token = getToken();
const headers = new Headers(init?.headers);
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
init = { ...init, headers };
}
const response = await original(input, init);
if (response.status === 401 && isApi && !isAuthEndpoint) {
localStorage.removeItem(TOKEN_KEY);
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
return response;
};
}
async function readNDJSON<T>(
response: Response,
onEvent: (event: T) => void
) {
if (!response.body) throw new Error('No body to stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIdx = buffer.indexOf('\n');
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (line) {
try {
onEvent(JSON.parse(line) as T);
} catch (e) {
console.error('Invalid NDJSON line:', line);
}
}
newlineIdx = buffer.indexOf('\n');
}
}
}
export async function POST(req: Request) {
const encoder = new TextEncoder();
const event = (data: Record<string, unknown>) =>
encoder.encode(JSON.stringify(data) + '\n');
const stream = new ReadableStream({
async start(controller) {
try {
controller.enqueue(event({ type: 'meta', /* ... */ }));
// Stream do LLM / processo longo
for await (const chunk of someAsyncIterable) {
controller.enqueue(event({ type: 'chunk', text: chunk }));
}
controller.enqueue(event({ type: 'done' }));
controller.close();
} catch (err) {
controller.enqueue(event({
type: 'error',
message: err instanceof Error ? err.message : 'unknown',
}));
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
}
import crypto from 'node:crypto';
function normalizeUrl(url: string): string {
try {
const u = new URL(url);
return `${u.host}${u.pathname}`.toLowerCase().replace(/\/$/, '');
} catch {
return url.toLowerCase().trim();
}
}
export function shortIdFromUrl(url: string): string {
const normalized = normalizeUrl(url);
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
return hash.slice(0, 10);
// 10 chars hex = 40 bits = ~1 trilhão de IDs únicos.
// Validate no consumo: /^[a-f0-9]{10}$/.test(id)
}
import crypto from 'node:crypto';
export function verifySignature(
payload: string,
signatureHeader: string | undefined,
secret: string
): boolean {
if (!signatureHeader) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(payload).digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}