1977 lines
73 KiB
HTML
1977 lines
73 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VaultUI - Gerenciador de Credenciais</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0a0f;
|
|
--bg-secondary: #12121a;
|
|
--bg-tertiary: #1a1a24;
|
|
--bg-card: #15151f;
|
|
--border-color: #2a2a3a;
|
|
--border-glow: #3a3a4a;
|
|
--accent-cyan: #00d4ff;
|
|
--accent-purple: #7c3aed;
|
|
--accent-green: #10b981;
|
|
--accent-red: #ef4444;
|
|
--accent-yellow: #f59e0b;
|
|
--accent-orange: #f97316;
|
|
--text-primary: #e2e8f0;
|
|
--text-secondary: #94a3b8;
|
|
--text-muted: #64748b;
|
|
--font-mono: 'JetBrains Mono', monospace;
|
|
--font-sans: 'Inter', system-ui, sans-serif;
|
|
--radius-sm: 6px;
|
|
--radius-md: 10px;
|
|
--radius-lg: 14px;
|
|
--shadow-glow: 0 0 20px rgba(0, 212, 255, 0.15);
|
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-sans);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background:
|
|
radial-gradient(ellipse at 20% 20%, rgba(124, 58, 237, 0.08) 0%, transparent 50%),
|
|
radial-gradient(ellipse at 80% 80%, rgba(0, 212, 255, 0.06) 0%, transparent 50%);
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
.container {
|
|
position: relative;
|
|
z-index: 1;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
/* Login Screen */
|
|
.login-screen {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--bg-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.login-screen.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.login-box {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 40px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: var(--shadow-card), var(--shadow-glow);
|
|
animation: fadeIn 0.5s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.login-logo {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.login-logo svg {
|
|
width: 64px;
|
|
height: 64px;
|
|
color: var(--accent-cyan);
|
|
filter: drop-shadow(0 0 10px rgba(0, 212, 255, 0.5));
|
|
}
|
|
|
|
.login-logo h1 {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.5rem;
|
|
color: var(--text-primary);
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.login-logo p {
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9rem;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.form-group input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-cyan);
|
|
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 12px 20px;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--accent-cyan), #0099cc);
|
|
color: var(--bg-primary);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.4);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
border-color: var(--accent-cyan);
|
|
background: rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: var(--accent-red);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.btn-icon {
|
|
padding: 8px;
|
|
background: transparent;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.btn-icon:hover {
|
|
border-color: var(--accent-cyan);
|
|
color: var(--accent-cyan);
|
|
background: rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.btn-icon svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.login-error {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
color: var(--accent-red);
|
|
padding: 12px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.875rem;
|
|
margin-bottom: 20px;
|
|
display: none;
|
|
animation: shake 0.5s ease;
|
|
}
|
|
|
|
@keyframes shake {
|
|
0%, 100% { transform: translateX(0); }
|
|
25% { transform: translateX(-5px); }
|
|
75% { transform: translateX(5px); }
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 16px 24px;
|
|
margin-bottom: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
box-shadow: var(--shadow-card);
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.logo svg {
|
|
width: 36px;
|
|
height: 36px;
|
|
color: var(--accent-cyan);
|
|
filter: drop-shadow(0 0 8px rgba(0, 212, 255, 0.5));
|
|
}
|
|
|
|
.logo h1 {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
|
|
.stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
|
|
.stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Search & Filters */
|
|
.filters-section {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 20px;
|
|
margin-bottom: 24px;
|
|
box-shadow: var(--shadow-card);
|
|
}
|
|
|
|
.search-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0 16px;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.search-input:focus-within {
|
|
border-color: var(--accent-cyan);
|
|
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.search-input svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-input input {
|
|
flex: 1;
|
|
padding: 12px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.search-input input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-input input:focus {
|
|
outline: none;
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.service-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.service-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 20px;
|
|
color: var(--text-secondary);
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.service-btn:hover {
|
|
border-color: var(--accent-cyan);
|
|
color: var(--accent-cyan);
|
|
background: rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.service-btn.active {
|
|
background: rgba(0, 212, 255, 0.15);
|
|
border-color: var(--accent-cyan);
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.service-btn .dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
|
|
.service-label {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 10px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-primary);
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
min-width: 150px;
|
|
}
|
|
|
|
.filter-select:focus {
|
|
outline: none;
|
|
border-color: var(--accent-cyan);
|
|
}
|
|
|
|
.filter-select option {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.filter-tags {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.filter-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 12px;
|
|
background: rgba(0, 212, 255, 0.1);
|
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.filter-tag button {
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent-cyan);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
display: flex;
|
|
}
|
|
|
|
.filter-tag button:hover {
|
|
color: var(--accent-red);
|
|
}
|
|
|
|
/* Cards Grid */
|
|
.cards-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.cards-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 20px;
|
|
transition: var(--transition);
|
|
box-shadow: var(--shadow-card);
|
|
animation: cardFadeIn 0.4s ease;
|
|
}
|
|
|
|
@keyframes cardFadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.card:hover {
|
|
border-color: var(--border-glow);
|
|
box-shadow: var(--shadow-card), 0 0 30px rgba(0, 212, 255, 0.1);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.card-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 212, 255, 0.1);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.card-icon svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.card-title h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.card-actions .btn-icon {
|
|
padding: 6px;
|
|
}
|
|
|
|
.card-actions .btn-icon svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.card-badges {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.badge-type {
|
|
background: rgba(124, 58, 237, 0.15);
|
|
color: #a78bfa;
|
|
border: 1px solid rgba(124, 58, 237, 0.3);
|
|
}
|
|
|
|
.badge-category {
|
|
background: rgba(16, 185, 129, 0.15);
|
|
color: var(--accent-green);
|
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
}
|
|
|
|
.badge-paid {
|
|
background: rgba(245, 158, 11, 0.15);
|
|
color: var(--accent-yellow);
|
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
}
|
|
|
|
.badge-expiring {
|
|
background: rgba(249, 115, 22, 0.15);
|
|
color: var(--accent-orange);
|
|
border: 1px solid rgba(249, 115, 22, 0.3);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
|
|
.card-value {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
padding: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.card-value-label {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.card-value-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.card-value-text {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8rem;
|
|
color: var(--accent-cyan);
|
|
word-break: break-all;
|
|
flex: 1;
|
|
}
|
|
|
|
.card-value-text.masked {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.card-meta-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.card-meta-item svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-meta-item a {
|
|
color: var(--accent-cyan);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.card-meta-item a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.card-login-info {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 8px;
|
|
padding-top: 8px;
|
|
border-top: 1px dashed var(--border-color);
|
|
}
|
|
|
|
.card-login-field {
|
|
flex: 1;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.card-login-field-label {
|
|
font-size: 0.65rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.card-login-field-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.card-login-field-text {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.75rem;
|
|
color: var(--accent-cyan);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.card-login-field-text.masked {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-login-copy {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
display: flex;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.card-login-copy:hover {
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.card-login-copy svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
/* Toast */
|
|
.toast-container {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
z-index: 2000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.toast {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 20px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
box-shadow: var(--shadow-card);
|
|
animation: toastIn 0.3s ease;
|
|
min-width: 280px;
|
|
}
|
|
|
|
@keyframes toastIn {
|
|
from { opacity: 0; transform: translateX(100px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
|
|
.toast.success {
|
|
border-color: var(--accent-green);
|
|
}
|
|
|
|
.toast.success svg {
|
|
color: var(--accent-green);
|
|
}
|
|
|
|
.toast.error {
|
|
border-color: var(--accent-red);
|
|
}
|
|
|
|
.toast.error svg {
|
|
color: var(--accent-red);
|
|
}
|
|
|
|
.toast svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toast span {
|
|
font-size: 0.875rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1500;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: var(--transition);
|
|
padding: 20px;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
width: 100%;
|
|
max-width: 600px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
transform: scale(0.9);
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.modal-overlay.active .modal {
|
|
transform: scale(1);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.modal-header h2 {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 24px;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
padding: 20px 24px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.form-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.form-group.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.form-group textarea,
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.9rem;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.form-group textarea {
|
|
resize: vertical;
|
|
min-height: 80px;
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group textarea:focus {
|
|
outline: none;
|
|
border-color: var(--accent-cyan);
|
|
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
|
}
|
|
|
|
.paid-section {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
padding: 16px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.paid-section h4 {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--accent-yellow);
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.checkbox-group input[type="checkbox"] {
|
|
width: 20px;
|
|
height: 20px;
|
|
accent-color: var(--accent-cyan);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.checkbox-group label {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.paid-fields {
|
|
display: none;
|
|
}
|
|
|
|
.paid-fields.show {
|
|
display: block;
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.empty-state svg {
|
|
width: 80px;
|
|
height: 80px;
|
|
margin-bottom: 20px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state h3 {
|
|
font-size: 1.125rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.empty-state p {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/* Confirm Dialog */
|
|
.confirm-dialog {
|
|
text-align: center;
|
|
}
|
|
|
|
.confirm-dialog svg {
|
|
width: 60px;
|
|
height: 60px;
|
|
color: var(--accent-red);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.confirm-dialog h3 {
|
|
font-size: 1.125rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.confirm-dialog p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.confirm-actions {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.container {
|
|
padding: 12px;
|
|
}
|
|
|
|
.header {
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.header-left {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.stats {
|
|
width: 100%;
|
|
justify-content: space-around;
|
|
}
|
|
|
|
.search-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-select {
|
|
width: 100%;
|
|
}
|
|
|
|
.header-actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.modal-footer {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-footer .btn {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border-color);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--border-glow);
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--border-color);
|
|
border-top-color: var(--accent-cyan);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* No results */
|
|
.no-results {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.no-results svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Main App -->
|
|
<div class="container" id="appContainer">
|
|
<!-- Header -->
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<div class="logo">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
|
<circle cx="12" cy="16" r="1"></circle>
|
|
</svg>
|
|
<h1>VaultUI</h1>
|
|
</div>
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<div class="stat-value" id="totalCount">0</div>
|
|
<div class="stat-label">Total</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="paidCount">0</div>
|
|
<div class="stat-label">Pagas</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="expiringCount">0</div>
|
|
<div class="stat-label">Expirando</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn-icon" id="exportBtn" title="Exportar JSON">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
<polyline points="7 10 12 15 17 10"></polyline>
|
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
</svg>
|
|
</button>
|
|
<button class="btn-icon" id="refreshBtn" title="Atualizar">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="23 4 23 10 17 10"></polyline>
|
|
<polyline points="1 20 1 14 7 14"></polyline>
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
|
</svg>
|
|
</button>
|
|
<button class="btn btn-primary" id="addBtn">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
</svg>
|
|
Novo
|
|
</button>
|
|
<button class="btn btn-secondary" id="logoutBtn">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
|
<polyline points="16 17 21 12 16 7"></polyline>
|
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Filters -->
|
|
<section class="filters-section">
|
|
<div class="search-row">
|
|
<div class="search-input">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<input type="text" id="searchInput" placeholder="Buscar por nome, categoria ou tags...">
|
|
</div>
|
|
</div>
|
|
<div class="filter-row">
|
|
<select class="filter-select" id="categoryFilter">
|
|
<option value="">Todas Categorias</option>
|
|
</select>
|
|
<select class="filter-select" id="typeFilter">
|
|
<option value="">Todos os Tipos</option>
|
|
</select>
|
|
<select class="filter-select" id="paidFilter">
|
|
<option value="">Todas</option>
|
|
<option value="paid">Apenas Pagas</option>
|
|
<option value="free">Apenas Gratuitas</option>
|
|
</select>
|
|
</div>
|
|
<div class="service-buttons" id="serviceButtons">
|
|
<div class="service-label">Serviços:</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Cards Grid -->
|
|
<div class="cards-grid" id="cardsGrid">
|
|
<!-- Cards will be rendered here -->
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div class="empty-state" id="emptyState" style="display: none;">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
</svg>
|
|
<h3>Nenhuma credencial encontrada</h3>
|
|
<p>Adicione uma nova credencial ou ajuste os filtros</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal: Add/Edit -->
|
|
<div class="modal-overlay" id="modalForm">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2 id="modalTitle">Nova Credencial</h2>
|
|
<button class="btn-icon" id="closeModalForm">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="credentialForm">
|
|
<input type="hidden" id="editId">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="name">Nome *</label>
|
|
<input type="text" id="name" placeholder="Ex: Coolify - App Key" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="dataType">Tipo do Dado *</label>
|
|
<select id="dataType" required>
|
|
<option value="">Selecione...</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="category">Categoria *</label>
|
|
<select id="category" required>
|
|
<option value="">Selecione...</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="subCategory">Subcategoria</label>
|
|
<input type="text" id="subCategory" placeholder="Ex: orchestrator">
|
|
</div>
|
|
</div>
|
|
<div class="form-group full-width">
|
|
<label for="value">Valor *</label>
|
|
<textarea id="value" rows="3" placeholder="Cole o valor aqui..." required></textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="url">URL</label>
|
|
<input type="url" id="url" placeholder="https://exemplo.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="associatedService">Serviço Associado</label>
|
|
<input type="text" id="associatedService" placeholder="Ex: Coolify">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="loginUsername">Usuário Web (para login)</label>
|
|
<input type="text" id="loginUsername" placeholder="Usuário para autenticação web">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="loginPassword">Senha Web (para login)</label>
|
|
<input type="text" id="loginPassword" placeholder="Senha para autenticação web">
|
|
</div>
|
|
</div>
|
|
<div class="form-group full-width">
|
|
<label for="notes">Notas</label>
|
|
<textarea id="notes" rows="2" placeholder="Anotações adicionais..."></textarea>
|
|
</div>
|
|
<div class="form-group full-width">
|
|
<label for="tags">Tags (separadas por vírgula)</label>
|
|
<input type="text" id="tags" placeholder="Ex: coolify, api, production">
|
|
</div>
|
|
<div class="paid-section">
|
|
<h4>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="1" x2="12" y2="23"></line>
|
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
|
</svg>
|
|
Informações de Pagamento
|
|
</h4>
|
|
<div class="checkbox-group">
|
|
<input type="checkbox" id="isPaid">
|
|
<label for="isPaid">Esta é uma credencial de serviço pago</label>
|
|
</div>
|
|
<div class="paid-fields" id="paidFields">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="cancellationDate">Data de Vencimento/Cancelamento</label>
|
|
<input type="date" id="cancellationDate">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="reminderDays">Lembrete (dias antes)</label>
|
|
<input type="number" id="reminderDays" value="7" min="1" max="30">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="cost">Custo</label>
|
|
<input type="text" id="cost" placeholder="Ex: R$ 5/mês">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="billingCycle">Ciclo de Cobrança</label>
|
|
<select id="billingCycle">
|
|
<option value="monthly">Mensal</option>
|
|
<option value="yearly">Anual</option>
|
|
<option value="pay-as-you-go">Pay-as-you-go</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" id="cancelForm">Cancelar</button>
|
|
<button type="submit" form="credentialForm" class="btn btn-primary">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
|
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
|
<polyline points="7 3 7 8 15 8"></polyline>
|
|
</svg>
|
|
Salvar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal: Confirm Delete -->
|
|
<div class="modal-overlay" id="modalConfirm">
|
|
<div class="modal" style="max-width: 400px;">
|
|
<div class="modal-body">
|
|
<div class="confirm-dialog">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
|
</svg>
|
|
<h3>Confirmar Exclusão</h3>
|
|
<p>Tem certeza que deseja excluir esta credencial? Esta ação não pode ser desfeita.</p>
|
|
<div class="confirm-actions">
|
|
<button class="btn btn-secondary" id="cancelDelete">Cancelar</button>
|
|
<button class="btn btn-danger" id="confirmDelete">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
Excluir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Container -->
|
|
<div class="toast-container" id="toastContainer"></div>
|
|
|
|
<script>
|
|
// State
|
|
let data = null;
|
|
let filteredData = [];
|
|
let currentFilter = {
|
|
search: '',
|
|
category: '',
|
|
type: '',
|
|
paid: '',
|
|
service: ''
|
|
};
|
|
let deleteTargetId = null;
|
|
let hiddenValues = new Set();
|
|
|
|
// Auth removido conforme pedido (usando apenas Caddy Basic Auth)
|
|
|
|
|
|
// DOM Elements
|
|
const appContainer = document.getElementById('appContainer');
|
|
|
|
const cardsGrid = document.getElementById('cardsGrid');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const categoryFilter = document.getElementById('categoryFilter');
|
|
const typeFilter = document.getElementById('typeFilter');
|
|
const paidFilter = document.getElementById('paidFilter');
|
|
const modalForm = document.getElementById('modalForm');
|
|
const modalConfirm = document.getElementById('modalConfirm');
|
|
const credentialForm = document.getElementById('credentialForm');
|
|
const toastContainer = document.getElementById('toastContainer');
|
|
|
|
// Icons
|
|
const icons = {
|
|
key: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>',
|
|
password: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>',
|
|
token: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>',
|
|
url: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>',
|
|
apiKey: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg>',
|
|
botToken: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"></path><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path d="m9.09 9-3 3"></path><circle cx="12" cy="12" r="1"></circle></svg>',
|
|
credentials: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>',
|
|
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>',
|
|
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
|
|
eyeOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>',
|
|
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>',
|
|
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>',
|
|
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>',
|
|
calendar: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>',
|
|
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>',
|
|
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
|
|
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>'
|
|
};
|
|
|
|
// Type icon mapping
|
|
const typeIcons = {
|
|
'key': icons.key,
|
|
'password': icons.password,
|
|
'token': icons.token,
|
|
'url': icons.link,
|
|
'chave-publica': icons.key,
|
|
'api-key': icons.apiKey,
|
|
'jwt-secret': icons.token,
|
|
'uri': icons.link,
|
|
'secret': icons.password,
|
|
'bot-token': icons.botToken,
|
|
'credentials': icons.credentials,
|
|
'username': icons.apiKey
|
|
};
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
checkAuth();
|
|
setupEventListeners();
|
|
});
|
|
|
|
function checkAuth() {
|
|
// Sempre mostra o app pois a autenticação é feita pelo Caddy
|
|
showApp();
|
|
triggerSync(); // Sincroniza automaticamente ao "logar" (carregar)
|
|
}
|
|
|
|
|
|
function setupEventListeners() {
|
|
document.getElementById('logoutBtn').style.display = 'none';
|
|
|
|
document.getElementById('addBtn').addEventListener('click', () => openModalForm());
|
|
document.getElementById('exportBtn').addEventListener('click', handleExport);
|
|
document.getElementById('refreshBtn').addEventListener('click', loadData);
|
|
document.getElementById('closeModalForm').addEventListener('click', () => closeModal(modalForm));
|
|
document.getElementById('cancelForm').addEventListener('click', () => closeModal(modalForm));
|
|
document.getElementById('cancelDelete').addEventListener('click', () => closeModal(modalConfirm));
|
|
document.getElementById('confirmDelete').addEventListener('click', handleConfirmDelete);
|
|
credentialForm.addEventListener('submit', handleFormSubmit);
|
|
searchInput.addEventListener('input', debounce(handleSearch, 300));
|
|
categoryFilter.addEventListener('change', handleFilterChange);
|
|
typeFilter.addEventListener('change', handleFilterChange);
|
|
paidFilter.addEventListener('change', handleFilterChange);
|
|
document.getElementById('isPaid').addEventListener('change', togglePaidFields);
|
|
}
|
|
|
|
function showApp() {
|
|
if (appContainer) appContainer.style.display = 'block';
|
|
loadData();
|
|
}
|
|
|
|
|
|
// EMBEDDED DATA - Dados incluídos diretamente no HTML
|
|
// DATA - Carregado via API
|
|
data = { credentials: [] };
|
|
|
|
async function loadData() {
|
|
try {
|
|
const response = await fetch('/keys.json');
|
|
data = await response.json();
|
|
populateFilters();
|
|
renderCards();
|
|
} catch (err) {
|
|
console.error('Erro ao carregar dados:', err);
|
|
showToast('Erro ao carregar credenciais do servidor', 'error');
|
|
}
|
|
}
|
|
|
|
async function triggerSync() {
|
|
showToast('Sincronizando com segredos.md...', 'success');
|
|
try {
|
|
const response = await fetch('/api/sync');
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
await loadData();
|
|
showToast('Sincronização concluída!', 'success');
|
|
}
|
|
} catch (err) {
|
|
showToast('Falha na sincronização', 'error');
|
|
}
|
|
}
|
|
|
|
function populateFilters() {
|
|
const categories = [...new Set(data.credentials.map(c => c.category))];
|
|
const types = [...new Set(data.credentials.map(c => c.dataType))];
|
|
|
|
categoryFilter.innerHTML = '<option value="">Todas Categorias</option>' +
|
|
categories.map(c => `<option value="${c}">${formatLabel(c)}</option>`).join('');
|
|
|
|
typeFilter.innerHTML = '<option value="">Todos os Tipos</option>' +
|
|
types.map(t => `<option value="${t}">${formatLabel(t)}</option>`).join('');
|
|
|
|
const dataTypeSelect = document.getElementById('dataType');
|
|
dataTypeSelect.innerHTML = '<option value="">Selecione...</option>' +
|
|
data.metadata.categories['tipo-dado'].map(t => `<option value="${t}">${formatLabel(t)}</option>`).join('');
|
|
|
|
const categorySelect = document.getElementById('category');
|
|
categorySelect.innerHTML = '<option value="">Selecione...</option>' +
|
|
data.metadata.categories.destino.map(c => `<option value="${c}">${formatLabel(c)}</option>`).join('');
|
|
|
|
populateServiceButtons();
|
|
}
|
|
|
|
function populateServiceButtons() {
|
|
const services = [...new Set(data.credentials.map(c => c.associatedService).filter(s => s))];
|
|
const serviceButtons = document.getElementById('serviceButtons');
|
|
|
|
const buttonsHtml = services.map(service => {
|
|
const count = data.credentials.filter(c => c.associatedService === service).length;
|
|
return `<button class="service-btn" data-service="${service}"><span class="dot"></span>${service} (${count})</button>`;
|
|
}).join('');
|
|
|
|
serviceButtons.innerHTML = `<div class="service-label">Serviços:</div>${buttonsHtml}`;
|
|
|
|
serviceButtons.querySelectorAll('.service-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const service = btn.dataset.service;
|
|
if (currentFilter.service === service) {
|
|
currentFilter.service = '';
|
|
btn.classList.remove('active');
|
|
} else {
|
|
document.querySelectorAll('.service-btn').forEach(b => b.classList.remove('active'));
|
|
currentFilter.service = service;
|
|
btn.classList.add('active');
|
|
}
|
|
renderCards();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderCards() {
|
|
applyFilters();
|
|
updateStats();
|
|
|
|
if (filteredData.length === 0) {
|
|
cardsGrid.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
cardsGrid.innerHTML = filteredData.map((cred, index) => createCardHTML(cred, index)).join('');
|
|
|
|
document.querySelectorAll('.card').forEach(card => {
|
|
const id = card.dataset.id;
|
|
card.querySelector('.copy-btn').addEventListener('click', () => copyToClipboard(id));
|
|
card.querySelector('.toggle-btn').addEventListener('click', () => toggleValue(id));
|
|
card.querySelector('.edit-btn').addEventListener('click', () => openModalForm(id));
|
|
card.querySelector('.delete-btn').addEventListener('click', () => openConfirmDelete(id));
|
|
});
|
|
}
|
|
|
|
function createCardHTML(cred, index) {
|
|
const icon = typeIcons[cred.dataType] || icons.key;
|
|
const isHidden = false; // Removido ocultação por máscara conforme pedido
|
|
const displayValue = cred.value;
|
|
const isExpiringSoon = checkExpiringSoon(cred);
|
|
|
|
let expirationHtml = '';
|
|
if (cred.isPaid && cred.paidInfo && cred.paidInfo.cancellationDate) {
|
|
const reminderDate = new Date(cred.paidInfo.cancellationDate);
|
|
reminderDate.setDate(reminderDate.getDate() - (cred.paidInfo.reminderDays || 7));
|
|
|
|
expirationHtml = `
|
|
<div class="card-meta-item">
|
|
${icons.calendar}
|
|
<span>Vencimento: ${formatDate(cred.paidInfo.cancellationDate)}</span>
|
|
</div>
|
|
${isExpiringSoon ? `
|
|
<div class="card-meta-item" style="color: var(--accent-orange);">
|
|
${icons.bell}
|
|
<span>Lembrete em ${getDaysUntil(reminderDate)} dias</span>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
let loginInfoHtml = '';
|
|
if (cred.loginUsername || cred.loginPassword) {
|
|
const isLoginHidden = false;
|
|
const displayUser = cred.loginUsername || '-';
|
|
const displayPass = cred.loginPassword || '-';
|
|
|
|
loginInfoHtml = `
|
|
<div class="card-login-info">
|
|
${cred.loginUsername ? `
|
|
<div class="card-login-field">
|
|
<div class="card-login-field-label">Usuário Web</div>
|
|
<div class="card-login-field-content">
|
|
<span class="card-login-field-text ${isLoginHidden ? 'masked' : ''}">${displayUser}</span>
|
|
<button class="card-login-copy" onclick="copyToClipboard('${cred.id}', 'loginUsername')" title="Copiar usuário">
|
|
${icons.copy}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
${cred.loginPassword ? `
|
|
<div class="card-login-field">
|
|
<div class="card-login-field-label">Senha Web</div>
|
|
<div class="card-login-field-content">
|
|
<span class="card-login-field-text">${displayPass}</span>
|
|
<button class="card-login-copy" onclick="copyToClipboard('${cred.id}', 'loginPassword')" title="Copiar senha">
|
|
${icons.copy}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="card" data-id="${cred.id}" style="animation-delay: ${index * 50}ms">
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
<div class="card-icon">${icon}</div>
|
|
<h3>${cred.name}</h3>
|
|
</div>
|
|
<div class="card-actions">
|
|
<button class="btn-icon copy-btn" title="Copiar">
|
|
${icons.copy}
|
|
</button>
|
|
<button class="btn-icon toggle-btn" title="${isHidden ? 'Mostrar' : 'Ocultar'}">
|
|
${isHidden ? icons.eye : icons.eyeOff}
|
|
</button>
|
|
<button class="btn-icon edit-btn" title="Editar">
|
|
${icons.edit}
|
|
</button>
|
|
<button class="btn-icon delete-btn" title="Excluir">
|
|
${icons.trash}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-badges">
|
|
<span class="badge badge-type">${formatLabel(cred.dataType)}</span>
|
|
<span class="badge badge-category">${formatLabel(cred.category)}</span>
|
|
${cred.isPaid ? '<span class="badge badge-paid">💳 Paga</span>' : '<span class="badge badge-category">Grátis</span>'}
|
|
${isExpiringSoon ? '<span class="badge badge-expiring">⚠️ Expirando</span>' : ''}
|
|
</div>
|
|
<div class="card-value">
|
|
<div class="card-value-label">Valor</div>
|
|
<div class="card-value-content">
|
|
<span class="card-value-text ${isHidden ? 'masked' : ''}">${displayValue}</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-meta">
|
|
${cred.url ? `
|
|
<div class="card-meta-item">
|
|
${icons.link}
|
|
<a href="${cred.url}" target="_blank" rel="noopener noreferrer">${cred.url}</a>
|
|
</div>
|
|
` : ''}
|
|
${cred.associatedService ? `
|
|
<div class="card-meta-item">
|
|
${icons.credentials}
|
|
<span>Serviço: ${cred.associatedService}</span>
|
|
</div>
|
|
` : ''}
|
|
${expirationHtml}
|
|
${cred.notes ? `
|
|
<div class="card-meta-item" style="flex-direction: column; align-items: flex-start;">
|
|
<span style="font-size: 0.75rem; color: var(--text-muted);">Notas:</span>
|
|
<span>${cred.notes}</span>
|
|
</div>
|
|
` : ''}
|
|
${loginInfoHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function applyFilters() {
|
|
filteredData = data.credentials.filter(cred => {
|
|
const searchMatch = currentFilter.search === '' ||
|
|
cred.name.toLowerCase().includes(currentFilter.search.toLowerCase()) ||
|
|
cred.category.toLowerCase().includes(currentFilter.search.toLowerCase()) ||
|
|
cred.tags?.some(tag => tag.toLowerCase().includes(currentFilter.search.toLowerCase())) ||
|
|
cred.notes?.toLowerCase().includes(currentFilter.search.toLowerCase());
|
|
|
|
const categoryMatch = currentFilter.category === '' || cred.category === currentFilter.category;
|
|
const typeMatch = currentFilter.type === '' || cred.dataType === currentFilter.type;
|
|
const paidMatch = currentFilter.paid === '' ||
|
|
(currentFilter.paid === 'paid' && cred.isPaid) ||
|
|
(currentFilter.paid === 'free' && !cred.isPaid);
|
|
const serviceMatch = currentFilter.service === '' || cred.associatedService === currentFilter.service;
|
|
|
|
return searchMatch && categoryMatch && typeMatch && paidMatch && serviceMatch;
|
|
});
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('totalCount').textContent = data.credentials.length;
|
|
document.getElementById('paidCount').textContent = data.credentials.filter(c => c.isPaid).length;
|
|
document.getElementById('expiringCount').textContent = data.credentials.filter(c => checkExpiringSoon(c)).length;
|
|
}
|
|
|
|
function checkExpiringSoon(cred) {
|
|
if (!cred.isPaid || !cred.paidInfo || !cred.paidInfo.cancellationDate) return false;
|
|
const reminderDate = new Date(cred.paidInfo.cancellationDate);
|
|
reminderDate.setDate(reminderDate.getDate() - (cred.paidInfo.reminderDays || 7));
|
|
const today = new Date();
|
|
const diffDays = Math.ceil((reminderDate - today) / (1000 * 60 * 60 * 24));
|
|
return diffDays <= 7 && diffDays >= 0;
|
|
}
|
|
|
|
function handleSearch(e) {
|
|
currentFilter.search = e.target.value;
|
|
renderCards();
|
|
}
|
|
|
|
function handleFilterChange() {
|
|
currentFilter.category = categoryFilter.value;
|
|
currentFilter.type = typeFilter.value;
|
|
currentFilter.paid = paidFilter.value;
|
|
renderCards();
|
|
}
|
|
|
|
async function copyToClipboard(id, field = null) {
|
|
const cred = data.credentials.find(c => c.id === id);
|
|
if (!cred) return;
|
|
|
|
try {
|
|
let textToCopy = cred.value;
|
|
let label = cred.name;
|
|
|
|
if (field === 'loginUsername') {
|
|
textToCopy = cred.loginUsername;
|
|
label = `${cred.name} - Usuário`;
|
|
} else if (field === 'loginPassword') {
|
|
textToCopy = cred.loginPassword;
|
|
label = `${cred.name} - Senha`;
|
|
}
|
|
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
showToast(`Copiado: ${label}`, 'success');
|
|
} catch (error) {
|
|
showToast('Erro ao copiar', 'error');
|
|
}
|
|
}
|
|
|
|
function toggleValue(id) {
|
|
if (hiddenValues.has(id)) {
|
|
hiddenValues.delete(id);
|
|
} else {
|
|
hiddenValues.add(id);
|
|
}
|
|
renderCards();
|
|
}
|
|
|
|
function openModalForm(id = null) {
|
|
credentialForm.reset();
|
|
document.getElementById('paidFields').classList.remove('show');
|
|
document.getElementById('isPaid').checked = false;
|
|
|
|
if (id) {
|
|
const cred = data.credentials.find(c => c.id === id);
|
|
if (cred) {
|
|
document.getElementById('editId').value = id;
|
|
document.getElementById('modalTitle').textContent = 'Editar Credencial';
|
|
document.getElementById('name').value = cred.name;
|
|
document.getElementById('dataType').value = cred.dataType;
|
|
document.getElementById('category').value = cred.category;
|
|
document.getElementById('subCategory').value = cred.subCategory || '';
|
|
document.getElementById('value').value = cred.value;
|
|
document.getElementById('url').value = cred.url || '';
|
|
document.getElementById('associatedService').value = cred.associatedService || '';
|
|
document.getElementById('loginUsername').value = cred.loginUsername || '';
|
|
document.getElementById('loginPassword').value = cred.loginPassword || '';
|
|
document.getElementById('notes').value = cred.notes || '';
|
|
document.getElementById('tags').value = cred.tags?.join(', ') || '';
|
|
|
|
if (cred.isPaid) {
|
|
document.getElementById('isPaid').checked = true;
|
|
document.getElementById('paidFields').classList.add('show');
|
|
document.getElementById('cancellationDate').value = cred.paidInfo?.cancellationDate || '';
|
|
document.getElementById('reminderDays').value = cred.paidInfo?.reminderDays || 7;
|
|
document.getElementById('cost').value = cred.paidInfo?.cost || '';
|
|
document.getElementById('billingCycle').value = cred.paidInfo?.billingCycle || 'monthly';
|
|
}
|
|
}
|
|
} else {
|
|
document.getElementById('editId').value = '';
|
|
document.getElementById('modalTitle').textContent = 'Nova Credencial';
|
|
}
|
|
|
|
openModal(modalForm);
|
|
}
|
|
|
|
function togglePaidFields() {
|
|
const isPaid = document.getElementById('isPaid').checked;
|
|
document.getElementById('paidFields').classList.toggle('show', isPaid);
|
|
}
|
|
|
|
async function handleFormSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const editId = document.getElementById('editId').value;
|
|
const isPaid = document.getElementById('isPaid').checked;
|
|
const loginUsername = document.getElementById('loginUsername').value || null;
|
|
const loginPassword = document.getElementById('loginPassword').value || null;
|
|
|
|
const credData = {
|
|
name: document.getElementById('name').value,
|
|
dataType: document.getElementById('dataType').value,
|
|
category: document.getElementById('category').value,
|
|
subCategory: document.getElementById('subCategory').value || null,
|
|
value: document.getElementById('value').value,
|
|
url: document.getElementById('url').value || null,
|
|
associatedService: document.getElementById('associatedService').value || null,
|
|
loginUsername: loginUsername,
|
|
loginPassword: loginPassword,
|
|
notes: document.getElementById('notes').value || null,
|
|
tags: document.getElementById('tags').value ?
|
|
document.getElementById('tags').value.split(',').map(t => t.trim()).filter(t => t) : [],
|
|
isPaid: isPaid,
|
|
paidInfo: isPaid ? {
|
|
cancellationDate: document.getElementById('cancellationDate').value || null,
|
|
reminderDays: parseInt(document.getElementById('reminderDays').value) || 7,
|
|
cost: document.getElementById('cost').value || null,
|
|
billingCycle: document.getElementById('billingCycle').value
|
|
} : null,
|
|
updatedAt: new Date().toISOString().split('T')[0]
|
|
};
|
|
|
|
if (editId) {
|
|
const index = data.credentials.findIndex(c => c.id === editId);
|
|
if (index !== -1) {
|
|
credData.createdAt = data.credentials[index].createdAt;
|
|
data.credentials[index] = { ...data.credentials[index], ...credData };
|
|
}
|
|
} else {
|
|
credData.id = generateId();
|
|
credData.createdAt = new Date().toISOString().split('T')[0];
|
|
data.credentials.push(credData);
|
|
}
|
|
|
|
data.lastUpdated = new Date().toISOString();
|
|
|
|
try {
|
|
await saveData();
|
|
closeModal(modalForm);
|
|
renderCards();
|
|
showToast(editId ? 'Credencial atualizada!' : 'Credencial criada!', 'success');
|
|
} catch (error) {
|
|
showToast('Erro ao salvar dados', 'error');
|
|
}
|
|
}
|
|
|
|
function openConfirmDelete(id) {
|
|
deleteTargetId = id;
|
|
openModal(modalConfirm);
|
|
}
|
|
|
|
async function handleConfirmDelete() {
|
|
if (!deleteTargetId) return;
|
|
|
|
const index = data.credentials.findIndex(c => c.id === deleteTargetId);
|
|
if (index !== -1) {
|
|
data.credentials.splice(index, 1);
|
|
data.lastUpdated = new Date().toISOString();
|
|
|
|
try {
|
|
await saveData();
|
|
closeModal(modalConfirm);
|
|
hiddenValues.delete(deleteTargetId);
|
|
renderCards();
|
|
showToast('Credencial excluída!', 'success');
|
|
} catch (error) {
|
|
showToast('Erro ao excluir', 'error');
|
|
}
|
|
}
|
|
|
|
deleteTargetId = null;
|
|
}
|
|
|
|
async function saveData() {
|
|
// Atualizar dados inline na memória
|
|
Object.assign(EMBEDDED_DATA, data);
|
|
showToast('Dados atualizados na memória (exportar para backup)', 'success');
|
|
}
|
|
|
|
function handleExport() {
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `keys-backup-${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
showToast('Backup exportado!', 'success');
|
|
}
|
|
|
|
function openModal(modal) {
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeModal(modal) {
|
|
modal.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.innerHTML = `
|
|
${type === 'success' ? icons.check : icons.alert}
|
|
<span>${message}</span>
|
|
`;
|
|
toastContainer.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'toastIn 0.3s ease reverse';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
function generateId() {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
}
|
|
|
|
function formatLabel(str) {
|
|
if (!str) return '';
|
|
return str.split('-').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ');
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('pt-BR');
|
|
}
|
|
|
|
function getDaysUntil(date) {
|
|
const today = new Date();
|
|
return Math.ceil((date - today) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|