first commit

This commit is contained in:
gustavooth
2026-07-26 21:17:57 -03:00
commit 9de08ceef7
133 changed files with 38427 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.svelte-kit
build
coverage
.env
.env.*
!.env.example
npm-debug.log*
*.log
.git
.gitignore
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:8080
VITE_API_POLL_INTERVAL=6000
VITE_DEMO_MODE=false
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+40
View File
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
# O repositório preserva adapter-auto para desenvolvimento. A imagem troca o
# adapter somente dentro do build, gerando um SPA estático servido pelo Nginx.
RUN npm install --no-save --ignore-scripts @sveltejs/adapter-static@3.0.10 \
&& sed -i \
-e 's#@sveltejs/adapter-auto#@sveltejs/adapter-static#' \
-e "s#adapter: adapter()#adapter: adapter({ fallback: 'index.html' })#" \
vite.config.ts \
&& npm run prepare
ARG VITE_API_URL=
ARG VITE_API_POLL_INTERVAL=6000
ARG VITE_DEMO_MODE=false
ENV VITE_API_URL=${VITE_API_URL} \
VITE_API_POLL_INTERVAL=${VITE_API_POLL_INTERVAL} \
VITE_DEMO_MODE=${VITE_DEMO_MODE}
RUN npm run build && test -f build/index.html
FROM nginx:1.29-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/build /usr/share/nginx/html
RUN nginx -t
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=5 \
CMD wget --quiet --output-document=/dev/null http://127.0.0.1:8080/health || exit 1
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.5 create --template minimal --types ts --add tailwindcss="plugins:none" --install npm frontend
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+57
View File
@@ -0,0 +1,57 @@
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend_upstream backend:8080;
charset utf-8;
client_max_body_size 2m;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://$backend_upstream$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
location /media/ {
proxy_pass http://$backend_upstream$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
location /_app/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1864
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"xlsx": "^0.18.5"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#181a2c" />
<meta name="robots" content="noindex, nofollow" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><title>LeadCast</title><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8c6bff"/><stop offset="1" stop-color="#5c39e8"/></linearGradient></defs><rect width="64" height="64" rx="21" fill="url(#g)"/><g fill="#fff"><rect x="19" y="23.5" width="6" height="17" rx="3" opacity="0.7"/><rect x="29" y="15" width="6" height="34" rx="3"/><rect x="39" y="19.5" width="6" height="25" rx="3" opacity="0.82"/></g></svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
let {
title,
message,
confirmLabel,
tone = 'primary',
busy = false,
onCancel,
onConfirm
}: {
title: string;
message: string;
confirmLabel: string;
tone?: 'danger' | 'primary';
busy?: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
} = $props();
let dialogElement: HTMLDivElement;
let cancelButton: HTMLButtonElement;
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
queueMicrotask(() => cancelButton.focus());
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not([disabled])'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && !busy) onCancel(); }}/>
<div class="modal-backdrop">
<div
bind:this={dialogElement}
class="confirm-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-description"
aria-busy={busy}
tabindex="-1"
onkeydown={trapFocus}
>
<div class={`confirm-icon ${tone === 'danger' ? 'danger' : 'primary'}`}><Icon name={tone === 'danger' ? 'alert' : 'sparkles'} size={23}/></div>
<button class="icon-button modal-close" type="button" onclick={onCancel} disabled={busy} aria-label="Fechar"><Icon name="x" size={18}/></button>
<h2 id="confirm-title">{title}</h2>
<p id="confirm-description">{message}</p>
<div class="modal-actions">
<button bind:this={cancelButton} class="button secondary" type="button" onclick={onCancel} disabled={busy}>Voltar</button>
<button class:danger-fill={tone === 'danger'} class="button primary" type="button" onclick={onConfirm} disabled={busy}>
{#if busy}<span class="spinner light"></span>{/if}{confirmLabel}
</button>
</div>
</div>
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import Icon from './Icon.svelte';
import type { Contact, ContactInput, ContactType, Interviewee } from '$lib/types';
let {
contact,
interviewees,
busy = false,
onClose,
onSave
}: {
contact?: Contact;
interviewees: Array<Pick<Interviewee, 'id' | 'displayName'>>;
busy?: boolean;
onClose: () => void;
onSave: (input: ContactInput) => void | Promise<void>;
} = $props();
let dialogElement: HTMLDivElement;
let form = $state<ContactInput>(untrack(() => ({
intervieweeId: contact?.intervieweeId ?? interviewees[0]?.id ?? '',
type: contact?.type ?? 'email',
value: contact?.value ?? '',
relationship: contact?.relationship ?? 'commercial',
label: contact?.label ?? '',
sourceName: contact?.sourceName ?? '',
sourceUrl: contact?.sourceUrl ?? '',
confidence: contact?.confidence ?? 100
})));
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
queueMicrotask(() => dialogElement.querySelector<HTMLElement>('input, select, textarea')?.focus());
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
const contactTypes: Array<{ value: ContactType; label: string }> = [
{ value: 'email', label: 'E-mail' }, { value: 'phone', label: 'Telefone' },
{ value: 'whatsapp', label: 'WhatsApp' }, { value: 'instagram', label: 'Instagram' },
{ value: 'linkedin', label: 'LinkedIn' }, { value: 'facebook', label: 'Facebook' },
{ value: 'tiktok', label: 'TikTok' }, { value: 'x', label: 'X' },
{ value: 'telegram', label: 'Telegram' }, { value: 'youtube', label: 'YouTube' },
{ value: 'website', label: 'Site' }, { value: 'other', label: 'Outro' }
];
function submit(event: SubmitEvent) {
event.preventDefault();
void onSave({
...form,
value: form.value.trim(),
label: form.label?.trim() || undefined,
sourceName: form.sourceName.trim(),
sourceUrl: form.sourceUrl.trim(),
confidence: Math.min(100, Math.max(0, Number(form.confidence) || 0))
});
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && !busy) onClose(); }}/>
<div class="modal-backdrop entity-modal-backdrop">
<div bind:this={dialogElement} class="entity-modal compact" role="dialog" aria-modal="true" aria-labelledby="contact-form-title" aria-describedby="contact-form-description" aria-busy={busy} tabindex="-1" onkeydown={trapFocus}>
<div class="entity-modal-header">
<div class="entity-modal-symbol contact"><Icon name="contact" size={22}/></div>
<div><span>Cadastro manual</span><h2 id="contact-form-title">{contact ? 'Editar contato' : 'Adicionar contato'}</h2><p id="contact-form-description">Todo contato precisa de uma origem pública verificável.</p></div>
<button class="icon-button" type="button" onclick={onClose} disabled={busy} aria-label="Fechar formulário"><Icon name="x" size={18}/></button>
</div>
<form class="entity-form" onsubmit={submit}>
<label><span>Entrevistado <b>*</b></span><select required bind:value={form.intervieweeId}><option value="" disabled>Selecione uma pessoa</option>{#each interviewees as person}<option value={person.id}>{person.displayName}</option>{/each}</select></label>
<div class="form-grid two-columns">
<label><span>Canal <b>*</b></span><select bind:value={form.type}>{#each contactTypes as type}<option value={type.value}>{type.label}</option>{/each}</select></label>
<label><span>Finalidade <b>*</b></span><select bind:value={form.relationship}><option value="commercial">Comercial</option><option value="personal">Pessoal</option></select></label>
</div>
<label><span>Contato <b>*</b></span><input required minlength="2" maxlength="320" bind:value={form.value} placeholder="E-mail, telefone, @perfil ou URL"/></label>
<label><span>Rótulo</span><input maxlength="120" bind:value={form.label} placeholder="Ex.: Assessoria, pessoal, palestras"/></label>
<div class="form-grid two-columns">
<label><span>Nome da origem <b>*</b></span><input required maxlength="160" bind:value={form.sourceName} placeholder="Ex.: Site oficial"/></label>
<label><span>URL da origem <b>*</b></span><input required type="url" bind:value={form.sourceUrl} placeholder="https://..."/></label>
</div>
<label><span>Confiança: <strong>{form.confidence ?? 100}%</strong></span><input class="range-input" type="range" min="0" max="100" step="1" bind:value={form.confidence}/></label>
<div class="entity-modal-actions"><button class="button secondary" type="button" onclick={onClose} disabled={busy}>Cancelar</button><button class="button primary" type="submit" disabled={busy || !interviewees.length}>{#if busy}<span class="spinner light"></span>{:else}<Icon name="check" size={16}/>{/if}{contact ? 'Salvar alterações' : 'Adicionar contato'}</button></div>
</form>
</div>
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { EXPORT_COLUMNS, EXPORT_FIELDS, EXPORT_SMART_FIELDS, type ExportColumnKey, type ExportFormat } from '$lib/export';
let {
busy = false,
onClose,
onExport
}: {
busy?: boolean;
onClose: () => void;
onExport: (options: { format: ExportFormat; columns: ExportColumnKey[]; filename: string; onlyWithBest: boolean }) => void | Promise<void>;
} = $props();
const ALL_COLUMNS = [...EXPORT_FIELDS, ...EXPORT_COLUMNS, ...EXPORT_SMART_FIELDS];
let dialogElement: HTMLDivElement;
let format = $state<ExportFormat>('xlsx');
let filename = $state('contatos');
let onlyWithBest = $state(false);
let columns = $state<Record<ExportColumnKey, boolean>>(
Object.fromEntries(ALL_COLUMNS.map((column) => [column.key, column.default])) as Record<ExportColumnKey, boolean>
);
const selectedCount = $derived(ALL_COLUMNS.filter((column) => columns[column.key]).length);
const showOnlyWithBest = $derived(columns.bestEmail || columns.bestPhone);
$effect(() => {
if (!showOnlyWithBest && onlyWithBest) onlyWithBest = false;
});
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
queueMicrotask(() => dialogElement.querySelector<HTMLElement>('input, select, button')?.focus());
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled])'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function toggleColumn(key: ExportColumnKey) {
if (ALL_COLUMNS.find((column) => column.key === key)?.locked) return;
columns = { ...columns, [key]: !columns[key] };
}
function submit(event: SubmitEvent) {
event.preventDefault();
const selected = ALL_COLUMNS.filter((column) => columns[column.key]).map((column) => column.key);
if (!selected.length) return;
void onExport({ format, columns: selected, filename: filename.trim() || 'contatos', onlyWithBest: showOnlyWithBest && onlyWithBest });
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && !busy) onClose(); }}/>
<div class="modal-backdrop entity-modal-backdrop">
<div bind:this={dialogElement} class="entity-modal compact" role="dialog" aria-modal="true" aria-labelledby="export-contacts-title" aria-describedby="export-contacts-description" aria-busy={busy} tabindex="-1" onkeydown={trapFocus}>
<div class="entity-modal-header">
<div class="entity-modal-symbol contact"><Icon name="download" size={22}/></div>
<div>
<span>Exportar dados</span>
<h2 id="export-contacts-title">Exportar contatos</h2>
<p id="export-contacts-description">O arquivo é processado pelo backend, com o filtro de busca atual e as colunas inteligentes melhor_email e melhor_numero calculadas pela IA.</p>
</div>
<button class="icon-button" type="button" onclick={onClose} disabled={busy} aria-label="Fechar exportação"><Icon name="x" size={18}/></button>
</div>
<form class="entity-form" onsubmit={submit}>
<label><span>Formato do arquivo <b>*</b></span>
<div class="export-format-options">
<button type="button" class={`export-option ${format === 'xlsx' ? 'active' : ''}`} onclick={() => (format = 'xlsx')}>
<Icon name="file-spreadsheet" size={18}/>
<div><strong>Microsoft Excel</strong><small>.xlsx</small></div>
</button>
<button type="button" class={`export-option ${format === 'csv' ? 'active' : ''}`} onclick={() => (format = 'csv')}>
<Icon name="file-text" size={18}/>
<div><strong>CSV</strong><small>.csv</small></div>
</button>
</div>
</label>
<label><span>Colunas <b>*</b> <small class="export-columns-count">{selectedCount} de {ALL_COLUMNS.length} selecionadas</small></span>
<p class="export-hint">Uma linha por entrevistado.</p>
<span class="export-group-title">Dados do entrevistado</span>
<div class="export-columns-grid">
{#each EXPORT_FIELDS as column}
<label class={`custom-checkbox export-column-option ${column.locked ? 'locked' : ''}`}>
<input type="checkbox" checked={columns[column.key]} disabled={column.locked} onchange={() => toggleColumn(column.key)}/>
<span><Icon name="check" size={13}/></span>
{column.header}{#if column.locked}<em>obrigatório</em>{/if}
</label>
{/each}
</div>
<span class="export-group-title">Canais de contato</span>
<div class="export-columns-grid">
{#each EXPORT_COLUMNS as column}
<label class={`custom-checkbox export-column-option ${column.locked ? 'locked' : ''}`}>
<input type="checkbox" checked={columns[column.key]} disabled={column.locked} onchange={() => toggleColumn(column.key)}/>
<span><Icon name="check" size={13}/></span>
{column.header}{#if column.locked}<em>obrigatório</em>{/if}
</label>
{/each}
</div>
<span class="export-group-title">Colunas inteligentes (calculadas pela IA)</span>
<div class="export-columns-grid">
{#each EXPORT_SMART_FIELDS as column}
<label class="custom-checkbox export-column-option">
<input type="checkbox" checked={columns[column.key]} onchange={() => toggleColumn(column.key)}/>
<span><Icon name="check" size={13}/></span>
{column.header}
</label>
{/each}
</div>
</label>
{#if showOnlyWithBest}
<label class="custom-checkbox export-column-option">
<input type="checkbox" checked={onlyWithBest} onchange={() => (onlyWithBest = !onlyWithBest)}/>
<span><Icon name="check" size={13}/></span>
Somente entrevistados com melhor e-mail ou melhor telefone preenchido
</label>
{/if}
<label><span>Nome do arquivo</span><input maxlength="80" bind:value={filename} placeholder="contatos"/></label>
<div class="entity-modal-actions">
<button class="button secondary" type="button" onclick={onClose} disabled={busy}>Cancelar</button>
<button class="button primary" type="submit" disabled={busy || !selectedCount}>
{#if busy}<span class="spinner light"></span>{:else}<Icon name="download" size={16}/>{/if}
Exportar
</button>
</div>
</form>
</div>
</div>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
let {
name,
size = 20,
strokeWidth = 1.8,
label
}: { name: string; size?: number; strokeWidth?: number; label?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden={label ? undefined : true}
role={label ? 'img' : undefined}
>
{#if label}<title>{label}</title>{/if}
{#if name === 'dashboard'}
<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>
{:else if name === 'search'}
<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>
{:else if name === 'podcast'}
<circle cx="12" cy="11" r="2"/><path d="M8.5 16.5a7 7 0 1 1 7 0M6 19a10 10 0 1 1 12 0M12 13v8"/>
{:else if name === 'users'}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
{:else if name === 'contact'}
<circle cx="12" cy="12" r="9"/><path d="M16 8v5a2 2 0 0 0 4 0v-1a8 8 0 1 0-3.3 6.5M16 12a4 4 0 1 1-4-4 4 4 0 0 1 4 4Z"/>
{:else if name === 'runs'}
<path d="M3 12h4l2.5-6 5 12 2.5-6h4"/>
{:else if name === 'reviews'}
<path d="M9 11l3 3L22 4"/><path d="M21 12a9 9 0 1 1-5.3-8.2"/>
{:else if name === 'plus'}
<path d="M12 5v14M5 12h14"/>
{:else if name === 'sparkles'}
<path d="m12 3-1.2 3.3L7.5 7.5l3.3 1.2L12 12l1.2-3.3 3.3-1.2-3.3-1.2L12 3Z"/><path d="m5 14-.8 2.2L2 17l2.2.8L5 20l.8-2.2L8 17l-2.2-.8L5 14ZM18.5 14l-.7 1.8-1.8.7 1.8.7.7 1.8.7-1.8 1.8-.7-1.8-.7-.7-1.8Z"/>
{:else if name === 'play'}
<path d="m8 5 11 7-11 7V5Z"/>
{:else if name === 'stop'}
<rect x="6" y="6" width="12" height="12" rx="2"/>
{:else if name === 'retry'}
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"/>
{:else if name === 'trash'}
<path d="M3 6h18M8 6V4h8v2M19 6l-1 15H6L5 6M10 11v5M14 11v5"/>
{:else if name === 'chevron-left'}
<path d="m15 18-6-6 6-6"/>
{:else if name === 'chevron-right'}
<path d="m9 18 6-6-6-6"/>
{:else if name === 'chevron-down'}
<path d="m6 9 6 6 6-6"/>
{:else if name === 'external'}
<path d="M15 3h6v6M10 14 21 3M18 13v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h7"/>
{:else if name === 'clock'}
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>
{:else if name === 'check'}
<path d="m5 12 4 4L19 6"/>
{:else if name === 'x'}
<path d="m6 6 12 12M18 6 6 18"/>
{:else if name === 'alert'}
<path d="M10.3 3.7 2.2 18a2 2 0 0 0 1.7 3h16.2a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/>
{:else if name === 'menu'}
<path d="M4 7h16M4 12h16M4 17h16"/>
{:else if name === 'bell'}
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>
{:else if name === 'filter'}
<path d="M4 5h16M7 12h10M10 19h4"/>
{:else if name === 'link'}
<path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/>
{:else if name === 'arrow-up-right'}
<path d="M7 17 17 7M7 7h10v10"/>
{:else if name === 'more'}
<circle cx="5" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none"/>
{:else if name === 'database'}
<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v7c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12v7c0 1.7 3.6 3 8 3s8-1.3 8-3v-7"/>
{:else if name === 'edit'}
<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/>
{:else if name === 'lock'}
<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>
{:else if name === 'user'}
<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>
{:else if name === 'eye'}
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="2.5"/>
{:else if name === 'eye-off'}
<path d="m3 3 18 18M10.6 6.2A9 9 0 0 1 12 6c6.5 0 10 6 10 6a17 17 0 0 1-2.1 2.8M6.6 6.6C3.6 8.4 2 12 2 12s3.5 6 10 6a9 9 0 0 0 3.4-.6M10.6 10.6a2 2 0 0 0 2.8 2.8"/>
{:else if name === 'logout'}
<path d="M10 17l5-5-5-5M15 12H3M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/>
{:else if name === 'shield'}
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>
{:else if name === 'download'}
<path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>
{:else if name === 'file-spreadsheet'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8M8 13v6M12 13v6M16 13v6"/>
{:else if name === 'file-text'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8"/>
{/if}
</svg>
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import Icon from './Icon.svelte';
import type { Interviewee, IntervieweeInput } from '$lib/types';
let {
interviewee,
busy = false,
onClose,
onSave
}: {
interviewee?: Interviewee;
busy?: boolean;
onClose: () => void;
onSave: (input: IntervieweeInput) => void | Promise<void>;
} = $props();
let dialogElement: HTMLDivElement;
let form = $state<IntervieweeInput>(untrack(() => ({
displayName: interviewee?.displayName ?? '',
realName: interviewee?.realName ?? '',
brandName: interviewee?.brandName ?? '',
category: interviewee?.category ?? '',
professionalSummary: interviewee?.professionalSummary ?? '',
publicBio: interviewee?.publicBio ?? '',
profession: interviewee?.profession ?? '',
contentType: interviewee?.contentType ?? '',
audience: interviewee?.audience ?? ''
})));
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
queueMicrotask(() => dialogElement.querySelector<HTMLElement>('input, select, textarea')?.focus());
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function submit(event: SubmitEvent) {
event.preventDefault();
void onSave({
...form,
displayName: form.displayName.trim(),
category: form.category.trim(),
professionalSummary: form.professionalSummary.trim(),
realName: form.realName?.trim() || undefined,
brandName: form.brandName?.trim() || undefined,
publicBio: form.publicBio?.trim() || undefined,
profession: form.profession?.trim() || undefined,
contentType: form.contentType?.trim() || undefined,
audience: form.audience?.trim() || undefined
});
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && !busy) onClose(); }}/>
<div class="modal-backdrop entity-modal-backdrop">
<div bind:this={dialogElement} class="entity-modal" role="dialog" aria-modal="true" aria-labelledby="interviewee-form-title" aria-describedby="interviewee-form-description" aria-busy={busy} tabindex="-1" onkeydown={trapFocus}>
<div class="entity-modal-header">
<div class="entity-modal-symbol"><Icon name="users" size={22}/></div>
<div><span>Cadastro manual</span><h2 id="interviewee-form-title">{interviewee ? 'Editar entrevistado' : 'Adicionar entrevistado'}</h2><p id="interviewee-form-description">Revise os dados públicos antes de salvar na base.</p></div>
<button class="icon-button" type="button" onclick={onClose} disabled={busy} aria-label="Fechar formulário"><Icon name="x" size={18}/></button>
</div>
<form class="entity-form" onsubmit={submit}>
<div class="form-grid two-columns">
<label><span>Nome de exibição <b>*</b></span><input required minlength="2" maxlength="160" bind:value={form.displayName} placeholder="Ex.: Camila Farani"/></label>
<label><span>Categoria <b>*</b></span><input required minlength="2" maxlength="100" bind:value={form.category} placeholder="Ex.: Empreendedorismo"/></label>
<label><span>Nome real</span><input maxlength="160" bind:value={form.realName} placeholder="Nome completo, se público"/></label>
<label><span>Nome fantasia ou marca</span><input maxlength="160" bind:value={form.brandName} placeholder="Empresa, projeto ou nome artístico"/></label>
<label><span>Profissão</span><input maxlength="200" bind:value={form.profession} placeholder="Ex.: Advogado, médica cardiologista"/></label>
</div>
<label><span>Resumo profissional <b>*</b></span><textarea required minlength="10" maxlength="1200" rows="3" bind:value={form.professionalSummary} placeholder="Descreva atuação, cargo e principais temas profissionais."></textarea><small>{form.professionalSummary.length}/1200</small></label>
<label><span>Biografia pública</span><textarea maxlength="1600" rows="3" bind:value={form.publicBio} placeholder="Contexto público adicional, sem informações sensíveis."></textarea></label>
<div class="form-grid two-columns">
<label><span>Tipo de conteúdo</span><input maxlength="160" bind:value={form.contentType} placeholder="Ex.: Finanças e investimentos"/></label>
<label><span>Público principal</span><input maxlength="160" bind:value={form.audience} placeholder="Ex.: Empreendedores iniciantes"/></label>
</div>
<div class="entity-modal-actions"><button class="button secondary" type="button" onclick={onClose} disabled={busy}>Cancelar</button><button class="button primary" type="submit" disabled={busy}>{#if busy}<span class="spinner light"></span>{:else}<Icon name="check" size={16}/>{/if}{interviewee ? 'Salvar alterações' : 'Adicionar entrevistado'}</button></div>
</form>
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { linkify, linkHref } from '$lib/format';
let { text }: { text: string | undefined | null } = $props();
const value = $derived(text ?? '');
const tokens = $derived(linkify(value));
</script>
{#each tokens as tok, i (i)}
{#if tok.kind === 'url'}
<a href={linkHref(tok.content)} target="_blank" rel="noreferrer">{tok.content}</a>
{:else}
{tok.content}
{/if}
{/each}
@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
let {
mode,
busy = false,
error = '',
notice = '',
onLogin
}: {
mode: 'live' | 'demo';
busy?: boolean;
error?: string;
notice?: string;
onLogin: (credentials: { username: string; password: string }) => void | Promise<void>;
} = $props();
let username = $state('');
let password = $state('');
let showPassword = $state(false);
let usernameInput: HTMLInputElement;
onMount(() => usernameInput.focus());
function submit(event: SubmitEvent) {
event.preventDefault();
void onLogin({ username: username.trim(), password });
}
</script>
<main class="login-screen">
<section class="login-story" aria-label="Sobre o LeadCast">
<div class="login-brand">
<div class="brand-mark" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="brand-copy"><strong>LeadCast</strong></div>
</div>
<div class="login-story-copy">
<span class="login-eyebrow"><Icon name="sparkles" size={14}/> Inteligência para prospecção</span>
<h2>Boas conversas começam com o contato certo.</h2>
<p>Centralize podcasts, entrevistados e canais públicos em uma operação segura e verificável.</p>
<div class="login-feature-list">
<div><span><Icon name="search" size={17}/></span><p><strong>Descoberta assistida</strong><small>Encontre canais e convidados relevantes.</small></p></div>
<div><span><Icon name="contact" size={17}/></span><p><strong>Contatos com origem</strong><small>Cada dado mantém sua evidência pública.</small></p></div>
<div><span><Icon name="shield" size={17}/></span><p><strong>Acesso protegido</strong><small>Sua sessão permanece em cookie seguro.</small></p></div>
</div>
</div>
<p class="login-story-footer">Lead intelligence · Brasil</p>
</section>
<section class="login-panel" aria-labelledby="login-title">
<div class="login-card">
<div class="login-mobile-brand" aria-hidden="true">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div class="brand-copy"><strong>LeadCast</strong></div>
</div>
<div class="login-lock"><Icon name="lock" size={22}/></div>
<span class="login-kicker">Área restrita</span>
<h1 id="login-title">Acesse sua operação</h1>
<p class="login-subtitle">Entre com suas credenciais para continuar no painel.</p>
{#if notice}
<div class="login-message info" role="status"><Icon name="clock" size={16}/><span>{notice}</span></div>
{/if}
{#if error}
<div id="login-error" class="login-message error" role="alert"><Icon name="alert" size={16}/><span>{error}</span></div>
{/if}
{#if mode === 'demo'}
<div class="login-demo-note"><Icon name="sparkles" size={14}/><span>Modo demonstração: use qualquer usuário e senha não vazios.</span></div>
{/if}
<form class="login-form" aria-busy={busy} onsubmit={submit}>
<label for="login-username">Usuário</label>
<div class="login-input">
<Icon name="user" size={17}/>
<input
bind:this={usernameInput}
id="login-username"
name="username"
type="text"
autocomplete="username"
required
maxlength="160"
placeholder="Seu usuário"
bind:value={username}
disabled={busy}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'login-error' : undefined}
/>
</div>
<label for="login-password">Senha</label>
<div class="login-input">
<Icon name="lock" size={17}/>
<input
id="login-password"
name="password"
type={showPassword ? 'text' : 'password'}
autocomplete="current-password"
required
placeholder="Sua senha"
bind:value={password}
disabled={busy}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'login-error' : undefined}
/>
<button
type="button"
class="login-password-toggle"
onclick={() => (showPassword = !showPassword)}
disabled={busy}
aria-label={showPassword ? 'Ocultar senha' : 'Mostrar senha'}
aria-pressed={showPassword}
><Icon name={showPassword ? 'eye-off' : 'eye'} size={17}/></button>
</div>
<button class="button primary login-submit" type="submit" disabled={busy || !username.trim() || !password}>
{#if busy}<span class="spinner light"></span>{:else}<Icon name="arrow-up-right" size={17}/>{/if}
{busy ? 'Validando acesso…' : 'Entrar no LeadCast'}
</button>
</form>
<p class="login-privacy"><Icon name="shield" size={13}/> {mode === 'demo' ? 'A senha demo não é armazenada nem enviada à rede.' : 'Suas credenciais são enviadas somente à API configurada.'}</p>
</div>
</section>
</main>
@@ -0,0 +1,38 @@
<script lang="ts">
import Icon from './Icon.svelte';
let {
page,
totalPages,
total,
pageSize,
onPage
}: { page: number; totalPages: number; total: number; pageSize: number; onPage: (page: number) => void } = $props();
function visiblePages() {
const start = Math.max(1, Math.min(page - 1, totalPages - 2));
const end = Math.min(totalPages, Math.max(3, page + 1));
return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index);
}
</script>
{#if total > 0}
<div class="pagination">
<p>
Exibindo <strong>{Math.min((page - 1) * pageSize + 1, total)}{Math.min(page * pageSize, total)}</strong>
de <strong>{total}</strong>
</p>
<div class="pagination-controls" aria-label="Paginação">
<button class="icon-button small" disabled={page <= 1} onclick={() => onPage(page - 1)} aria-label="Página anterior">
<Icon name="chevron-left" size={17}/>
</button>
{#each visiblePages() as number}
<button class:active={number === page} class="page-button" onclick={() => onPage(number)} aria-label={`Página ${number}`} aria-current={number === page ? 'page' : undefined}>{number}</button>
{/each}
<button class="icon-button small" disabled={page >= totalPages} onclick={() => onPage(page + 1)} aria-label="Próxima página">
<Icon name="chevron-right" size={17}/>
</button>
</div>
</div>
{/if}
+53
View File
@@ -0,0 +1,53 @@
import type { ContactType } from './types';
export type ExportFormat = 'csv' | 'xlsx';
export type ExportFieldKey = 'name' | 'createdAt' | 'category' | 'profession' | 'description';
export type ExportSmartFieldKey = 'bestEmail' | 'bestPhone';
export type ExportColumnKey = ExportFieldKey | ContactType | ExportSmartFieldKey;
// O processamento da exportação (CSV/XLSX) é feito no backend. Este arquivo só
// guarda a definição das colunas disponíveis para o modal de exportação e o
// utilitário de download do arquivo já pronto.
// Dados básicos do entrevistado.
export const EXPORT_FIELDS: Array<{ key: ExportFieldKey; header: string; default: boolean; locked?: boolean }> = [
{ key: 'name', header: 'Nome', default: true, locked: true },
{ key: 'createdAt', header: 'Data', default: false },
{ key: 'category', header: 'Categoria', default: true },
{ key: 'profession', header: 'Profissão', default: false },
{ key: 'description', header: 'Bio', default: false }
];
// Canais de contato (redes sociais, e-mail, telefone, sites etc.).
export const EXPORT_COLUMNS: Array<{ key: ContactType; header: string; default: boolean; locked?: boolean }> = [
{ key: 'email', header: 'E-mail', default: true, locked: true },
{ key: 'phone', header: 'Telefone', default: true, locked: true },
{ key: 'whatsapp', header: 'WhatsApp', default: true },
{ key: 'instagram', header: 'Instagram', default: true },
{ key: 'linkedin', header: 'LinkedIn', default: true },
{ key: 'website', header: 'Site', default: true },
{ key: 'facebook', header: 'Facebook', default: false },
{ key: 'tiktok', header: 'TikTok', default: false },
{ key: 'x', header: 'X', default: false },
{ key: 'telegram', header: 'Telegram', default: false },
{ key: 'youtube', header: 'YouTube', default: false },
{ key: 'other', header: 'Outro', default: false }
];
// Colunas "inteligentes" calculadas pela IA a partir dos contatos verificados.
export const EXPORT_SMART_FIELDS: Array<{ key: ExportSmartFieldKey; header: string; default: boolean; locked?: boolean }> = [
{ key: 'bestEmail', header: 'Melhor e-mail', default: false },
{ key: 'bestPhone', header: 'Melhor telefone', default: false }
];
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
+42
View File
@@ -0,0 +1,42 @@
/** Converte um valor de confiança (0-1 ou 0-100, eventualmente float ruidoso) em
* percentual inteiro para exibição na interface. */
export function formatConfidence(value: number): number {
const clamped = Math.max(0, Math.min(100, value));
return Math.round(clamped);
}
export type LinkToken = { kind: "text" | "url"; content: string };
const URL_RE =
/https?:\/\/[^\s<>"'`)]+|www\.[^\s<>"'`)]+/gi;
/** Divide um texto em segmentos, transformando URLs encontradas em tokens
* clicáveis. Uso com `{#each linkify(text) as tok}` para evitar `{@html}`. */
export function linkify(text: string): LinkToken[] {
if (!text) return [];
const tokens: LinkToken[] = [];
let last = 0;
for (const match of text.matchAll(URL_RE)) {
const start = match.index ?? 0;
const raw = match[0];
if (start > last) {
tokens.push({ kind: "text", content: text.slice(last, start) });
}
const href = raw.startsWith("http")
? raw
: `https://${raw}`;
tokens.push({ kind: "url", content: raw });
void href;
last = start + raw.length;
}
if (last < text.length) {
tokens.push({ kind: "text", content: text.slice(last) });
}
return tokens;
}
/** Retorna a href já normalizada com protocolo (para tokens de URL). */
export function linkHref(content: string): string {
return content.startsWith("http") ? content : `https://${content}`;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+232
View File
@@ -0,0 +1,232 @@
export type PageResponse<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
export type AuthSession = {
username: string;
};
export type PodcastStatus = 'ready' | 'discovering' | 'extracting' | 'paused' | 'error';
export type Podcast = {
id: string;
youtubeChannelId?: string;
name: string;
url: string;
logoUrl?: string;
description?: string;
status: PodcastStatus;
videosCount: number;
intervieweesCount: number;
lastSyncedAt?: string;
createdAt: string;
};
export type PodcastDiscoveryResult = {
id: string;
name: string;
url: string;
logoUrl?: string;
description: string;
subscribersText?: string;
alreadyAdded: boolean;
};
export type IntervieweeStatus = 'enriched' | 'partial' | 'review' | 'queued';
export type Interviewee = {
id: string;
displayName: string;
realName?: string;
brandName?: string;
avatarUrl?: string;
category: string;
professionalSummary: string;
publicBio?: string;
profession?: string;
contentType?: string;
audience?: string;
status: IntervieweeStatus;
appearancesCount: number;
contactsCount: number;
confidence: number;
lastEnrichedAt?: string;
createdAt: string;
};
export type IntervieweeInput = {
displayName: string;
realName?: string;
brandName?: string;
category: string;
professionalSummary: string;
publicBio?: string;
profession?: string;
contentType?: string;
audience?: string;
};
export type IntervieweePatch = Partial<{
displayName: string;
realName: string | null;
brandName: string | null;
category: string | null;
professionalSummary: string | null;
publicBio: string | null;
profession: string | null;
contentType: string | null;
audience: string | null;
}>;
export type ContactType =
| 'email'
| 'phone'
| 'whatsapp'
| 'instagram'
| 'linkedin'
| 'facebook'
| 'tiktok'
| 'x'
| 'telegram'
| 'youtube'
| 'website'
| 'other';
export type ContactRelationship = 'commercial' | 'personal';
export type ContactStatus = 'verified' | 'pending' | 'rejected' | 'review';
export type Contact = {
id: string;
intervieweeId: string;
intervieweeName: string;
avatarUrl?: string;
type: ContactType;
value: string;
relationship: ContactRelationship;
label?: string;
status: ContactStatus;
confidence: number;
sourceName: string;
sourceUrl: string;
lastVerifiedAt?: string;
createdAt: string;
};
export type ContactInput = {
intervieweeId: string;
type: ContactType;
value: string;
relationship: ContactRelationship;
label?: string;
sourceName: string;
sourceUrl: string;
confidence?: number;
};
export type ContactPatch = Partial<Omit<ContactInput, 'label'>> & {
label?: string | null;
};
export type RunType = 'podcast_discovery' | 'interviewee_extraction' | 'contact_extraction';
export type RunStatus = 'queued' | 'running' | 'paused' | 'completed' | 'cancelled' | 'failed';
export type ExecutionRun = {
id: string;
name: string;
type: RunType;
status: RunStatus;
stage: string;
progress: number;
processed: number;
total: number;
currentTarget?: string;
errorsCount: number;
startedAt?: string;
finishedAt?: string;
createdAt: string;
};
export type ReviewKind = 'identity' | 'contact' | 'category' | 'merge';
export type ReviewPriority = 'high' | 'medium' | 'low';
export type ReviewItem = {
id: string;
kind: ReviewKind;
priority: ReviewPriority;
title: string;
subject: string;
summary: string;
proposedValue: string;
evidence: string;
confidence: number;
sourceName?: string;
sourceUrl?: string;
createdAt: string;
};
export type DashboardData = {
metrics: {
podcasts: number;
interviewees: number;
contacts: number;
activeRuns: number;
pendingReviews: number;
};
changes: {
podcasts: number;
interviewees: number;
contacts: number;
};
contactDistribution: Array<{ type: ContactType; count: number; percentage: number }>;
recentRuns: ExecutionRun[];
successRate: number;
contactsThisWeek: number;
};
export type AiUsage = {
month: string;
spentUsd: number;
limitUsd: number;
remainingUsd: number;
percentUsed: number;
inputCostPerMillionUsd: number;
outputCostPerMillionUsd: number;
limitExceeded: boolean;
};
export type MaintenanceSummary = {
runsStopped: number;
videosReset: number;
pipelineRunsReset: number;
jobsReset: number;
crawlPagesReset: number;
};
export type ListFilters = {
query?: string;
page?: number;
pageSize?: number;
};
export type PodcastFilters = ListFilters & { status?: PodcastStatus | 'all' };
export type IntervieweeFilters = ListFilters & {
status?: IntervieweeStatus | 'all';
category?: string;
};
export type ContactFilters = ListFilters & {
type?: ContactType | 'all';
relationship?: ContactRelationship | 'all';
status?: ContactStatus | 'all';
};
export type RunFilters = ListFilters & { status?: RunStatus | 'all'; type?: RunType | 'all' };
export type ReviewSort = 'priority' | 'confidence';
export type ReviewFilters = ListFilters & {
kind?: ReviewKind | 'all';
priority?: ReviewPriority | 'all';
sort?: ReviewSort;
};
export type ReviewDecision = 'approved' | 'rejected';
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# block crawling everything
User-agent: *
Disallow: /
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+21
View File
@@ -0,0 +1,21 @@
import tailwindcss from '@tailwindcss/vite';
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
})
]
});