first commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
coverage
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_API_POLL_INTERVAL=6000
|
||||
VITE_DEMO_MODE=false
|
||||
@@ -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-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1864
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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';
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
# block crawling everything
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user