feat(core): implement secure MVP core with dictionary search, auth, and CRUD
This commit is contained in:
1 parent
4dbc53890a
commit
813d567b0e
14 files changed
+2960
No files matched your search
@@ -0,0 +1,951 @@
|
||||
/* Modern CSS Design System for Notes App */
|
||||
:root {
|
||||
--bg-main: #0f172a;
|
||||
--bg-surface: #1e293b;
|
||||
--bg-surface-hover: #334155;
|
||||
--bg-card: #1e293b;
|
||||
--bg-input: #0f172a;
|
||||
--border-color: #334155;
|
||||
--border-focus: #38bdf8;
|
||||
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
|
||||
--primary: #38bdf8;
|
||||
--primary-hover: #0ea5e9;
|
||||
--primary-dim: rgba(56, 189, 248, 0.15);
|
||||
|
||||
--success: #10b981;
|
||||
--success-hover: #059669;
|
||||
--success-dim: rgba(16, 185, 129, 0.15);
|
||||
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #dc2626;
|
||||
--danger-dim: rgba(239, 68, 68, 0.15);
|
||||
|
||||
--tag-bg: #293548;
|
||||
--tag-text: #7dd3fc;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
--transition: all 0.18s ease-in-out;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-main: #f1f5f9;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-hover: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #f8fafc;
|
||||
--border-color: #cbd5e1;
|
||||
--border-focus: #0284c7;
|
||||
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
|
||||
--primary: #0284c7;
|
||||
--primary-hover: #0369a1;
|
||||
--primary-dim: rgba(2, 132, 199, 0.1);
|
||||
|
||||
--success: #059669;
|
||||
--success-hover: #047857;
|
||||
--success-dim: rgba(5, 150, 105, 0.1);
|
||||
|
||||
--tag-bg: #e2e8f0;
|
||||
--tag-text: #0369a1;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Auth Screen */
|
||||
.auth-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: radial-gradient(circle at top, rgba(56, 189, 248, 0.12), transparent 70%);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auth-header .app-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-main);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--primary-dim);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background-color: var(--danger-dim);
|
||||
border: 1px solid var(--danger);
|
||||
color: #fca5a5;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* App Navbar */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
background-color: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.nav-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
font-size: 13px;
|
||||
background-color: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-badge strong {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.stat-badge.stat-today strong {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
background-color: var(--bg-surface-hover);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: var(--success-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
border-color: var(--text-main);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-subtext {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-main);
|
||||
border-color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.search-box-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-surface);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 10px 6px 16px;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.search-input-box:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 4px var(--primary-dim);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.search-input-box input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 17px;
|
||||
color: var(--text-main);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.translate-action-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.hotkey-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hotkey-hints kbd {
|
||||
background-color: var(--bg-surface-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.lookup-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Live Result Card */
|
||||
.result-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.result-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.term-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.res-term {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.badge-pill {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.res-phonetic {
|
||||
background-color: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.res-pos {
|
||||
background-color: var(--bg-surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audio-btn {
|
||||
background-color: var(--primary-dim);
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.audio-btn:hover {
|
||||
background-color: var(--primary);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.definition-box {
|
||||
background-color: var(--bg-main);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.def-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.def-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.card-edit-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-styled {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-main);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.input-styled:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--primary-dim);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.field-group .req {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.flex-1 { flex: 1; }
|
||||
.flex-2 { flex: 2; }
|
||||
|
||||
.suggestions-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
font-size: 12px;
|
||||
background-color: var(--bg-surface-hover);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.suggestion-chip:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.quick-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
font-size: 11px;
|
||||
background-color: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.tag-chip:hover, .tag-chip.active {
|
||||
background-color: var(--primary);
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Vocabulary Section */
|
||||
.vocabulary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title-wrap h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cards-count-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.search-mini-box input {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-width: 260px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-mini-box input:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
/* Tags Filter Bar */
|
||||
.tags-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Cards List */
|
||||
.cards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vocab-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vocab-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-term-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-term {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.card-phonetic {
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.card-pos {
|
||||
font-size: 11px;
|
||||
background-color: var(--bg-surface-hover);
|
||||
color: var(--text-dim);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-translation {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.card-context {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
background-color: var(--bg-main);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-context mark {
|
||||
background-color: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background-color: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-notes {
|
||||
font-size: 12px;
|
||||
color: #fbbf24;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state h4 {
|
||||
font-size: 16px;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-window {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.navbar {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.brand-sub {
|
||||
display: none;
|
||||
}
|
||||
.main-content {
|
||||
padding: 16px 12px;
|
||||
gap: 24px;
|
||||
}
|
||||
.field-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.res-term {
|
||||
font-size: 22px;
|
||||
}
|
||||
.hotkey-hints {
|
||||
display: none;
|
||||
}
|
||||
.search-mini-box input {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Notes — Персональный тренажёр английского языка</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login Modal / Screen -->
|
||||
<div id="auth-screen" class="auth-screen hidden">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="app-logo">📖</div>
|
||||
<h2>Вход в Notes</h2>
|
||||
<p>Персональный словарь и тренажёр языка</p>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" autocomplete="username" required placeholder="admin" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<div id="auth-error" class="error-banner hidden"></div>
|
||||
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Войти в систему</button>
|
||||
</form>
|
||||
<div class="auth-footer">
|
||||
<span>Защищённый контур: notes.qpid.pro</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Application UI -->
|
||||
<div id="app-container" class="app-container hidden">
|
||||
<!-- Top Navigation -->
|
||||
<header class="navbar">
|
||||
<div class="nav-brand">
|
||||
<span class="brand-icon">📖</span>
|
||||
<span class="brand-title">Notes</span>
|
||||
<span class="brand-sub">English Lexicon</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-stats" id="nav-stats">
|
||||
<span class="stat-badge" title="Всего слов в словаре">Всего: <strong id="stat-total">0</strong></span>
|
||||
<span class="stat-badge stat-today" title="Добавлено сегодня">Сегодня: <strong id="stat-today">+0</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="nav-actions">
|
||||
<button id="theme-toggle" class="icon-btn" title="Переключить тему">🌓</button>
|
||||
<div class="user-profile">
|
||||
<span id="user-display-name" class="user-badge">user</span>
|
||||
<button id="logout-btn" class="btn btn-sm btn-outline" title="Выйти из аккаунта">Выход</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Search & Translation Section -->
|
||||
<section class="search-section">
|
||||
<div class="search-box-wrapper">
|
||||
<div class="search-input-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="term-input" placeholder="Введите слово, фразовый глагол или предложение..." autocomplete="off" autofocus>
|
||||
<button id="clear-search-btn" class="clear-btn hidden" title="Очистить (Esc)">✕</button>
|
||||
<button id="translate-btn" class="btn btn-primary translate-action-btn">Перевести</button>
|
||||
</div>
|
||||
|
||||
<div class="hotkey-hints">
|
||||
<span class="hint-item"><kbd>Enter</kbd> перевод</span>
|
||||
<span class="hint-item"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> сохранить</span>
|
||||
<span class="hint-item"><kbd>Esc</kbd> сброс</span>
|
||||
<span class="hint-item"><kbd>Space</kbd> / <kbd>🔊</kbd> озвучить</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div id="lookup-spinner" class="lookup-spinner hidden">
|
||||
<div class="spinner"></div>
|
||||
<span>Ищем толкование, произношение и перевод...</span>
|
||||
</div>
|
||||
|
||||
<!-- Live Result Card -->
|
||||
<div id="result-card" class="result-card hidden">
|
||||
<div class="result-header">
|
||||
<div class="term-title-row">
|
||||
<h2 id="res-term" class="res-term">reluctant</h2>
|
||||
<span id="res-phonetic" class="res-phonetic badge-pill">/rɪˈlʌktənt/</span>
|
||||
<span id="res-pos" class="res-pos badge-pill">Adjective</span>
|
||||
<button id="play-audio-btn" class="audio-btn" title="Прослушать произношение">
|
||||
🔊 <span class="audio-text">Слушать</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="res-definition-box" class="definition-box">
|
||||
<div class="def-label">Толкование (English):</div>
|
||||
<p id="res-definition" class="def-text"></p>
|
||||
</div>
|
||||
|
||||
<div class="card-edit-grid">
|
||||
<div class="field-group">
|
||||
<label for="edit-translation">Перевод на русский <span class="req">*</span></label>
|
||||
<input type="text" id="edit-translation" class="input-styled" placeholder="Основной перевод...">
|
||||
<div id="translation-suggestions" class="suggestions-chips"></div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="edit-context">Контекстное предложение (где встретилось?)</label>
|
||||
<input type="text" id="edit-context" class="input-styled" placeholder="Например: The switch drops the frame if the VLAN ID is invalid.">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group flex-2">
|
||||
<label for="edit-tags">Теги (через запятую)</label>
|
||||
<input type="text" id="edit-tags" class="input-styled" placeholder="general, phrasal-verb, networking">
|
||||
<div class="quick-tags" id="quick-tags-bar">
|
||||
<span class="tag-chip" data-tag="general">#general</span>
|
||||
<span class="tag-chip" data-tag="phrasal-verb">#phrasal-verb</span>
|
||||
<span class="tag-chip" data-tag="idiom">#idiom</span>
|
||||
<span class="tag-chip" data-tag="daily">#daily</span>
|
||||
<span class="tag-chip" data-tag="tech">#tech</span>
|
||||
<span class="tag-chip" data-tag="work">#work</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group flex-1">
|
||||
<label for="edit-notes">Заметка / Мнемоника</label>
|
||||
<input type="text" id="edit-notes" class="input-styled" placeholder="Ассоциация...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<button id="save-card-btn" class="btn btn-success btn-lg">
|
||||
💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Vocabulary Notebook & History -->
|
||||
<section class="vocabulary-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrap">
|
||||
<h3>📚 Мой словарь</h3>
|
||||
<span id="cards-count-label" class="cards-count-label">(0 записей)</span>
|
||||
</div>
|
||||
|
||||
<div class="list-controls">
|
||||
<div class="search-mini-box">
|
||||
<input type="text" id="filter-input" placeholder="Фильтр по слову, переводу, контексту...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tags Filter -->
|
||||
<div id="tags-filter-bar" class="tags-filter-bar">
|
||||
<button class="filter-chip active" data-tag="">Все теги</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid/List -->
|
||||
<div id="cards-list" class="cards-list">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-icon">🌱</div>
|
||||
<h4>Ваш словарь пока пуст</h4>
|
||||
<p>Введите первое слово или фразу в поле поиска выше и сохраните её!</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Edit Card Modal -->
|
||||
<div id="edit-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window">
|
||||
<div class="modal-header">
|
||||
<h3>Редактирование карточки</h3>
|
||||
<button id="close-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modal-card-id">
|
||||
<div class="form-group">
|
||||
<label>Слово / Фраза</label>
|
||||
<input type="text" id="modal-term" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Перевод на русский</label>
|
||||
<input type="text" id="modal-translation" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Контекстное предложение</label>
|
||||
<input type="text" id="modal-context" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Теги</label>
|
||||
<input type="text" id="modal-tags" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки / Мнемоника</label>
|
||||
<input type="text" id="modal-notes" class="input-styled">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="modal-cancel-btn" class="btn btn-outline">Отмена</button>
|
||||
<button id="modal-save-btn" class="btn btn-primary">Сохранить изменения</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,650 @@
|
||||
// Notes Application Frontend Logic
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// State
|
||||
let currentUser = null;
|
||||
let currentLookupData = null;
|
||||
let allCards = [];
|
||||
let selectedTag = '';
|
||||
let searchQuery = '';
|
||||
|
||||
// Elements - Auth
|
||||
const authScreen = document.getElementById('auth-screen');
|
||||
const appContainer = document.getElementById('app-container');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const authError = document.getElementById('auth-error');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const userDisplayName = document.getElementById('user-display-name');
|
||||
|
||||
// Elements - Navbar & Stats
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const statTotal = document.getElementById('stat-total');
|
||||
const statToday = document.getElementById('stat-today');
|
||||
|
||||
// Elements - Search & Result
|
||||
const termInput = document.getElementById('term-input');
|
||||
const clearSearchBtn = document.getElementById('clear-search-btn');
|
||||
const translateBtn = document.getElementById('translate-btn');
|
||||
const lookupSpinner = document.getElementById('lookup-spinner');
|
||||
const resultCard = document.getElementById('result-card');
|
||||
const resTerm = document.getElementById('res-term');
|
||||
const resPhonetic = document.getElementById('res-phonetic');
|
||||
const resPos = document.getElementById('res-pos');
|
||||
const playAudioBtn = document.getElementById('play-audio-btn');
|
||||
const resDefinitionBox = document.getElementById('res-definition-box');
|
||||
const resDefinition = document.getElementById('res-definition');
|
||||
const editTranslation = document.getElementById('edit-translation');
|
||||
const translationSuggestions = document.getElementById('translation-suggestions');
|
||||
const editContext = document.getElementById('edit-context');
|
||||
const editTags = document.getElementById('edit-tags');
|
||||
const quickTagsBar = document.getElementById('quick-tags-bar');
|
||||
const editNotes = document.getElementById('edit-notes');
|
||||
const saveCardBtn = document.getElementById('save-card-btn');
|
||||
|
||||
// Elements - Vocabulary List
|
||||
const filterInput = document.getElementById('filter-input');
|
||||
const tagsFilterBar = document.getElementById('tags-filter-bar');
|
||||
const cardsList = document.getElementById('cards-list');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const cardsCountLabel = document.getElementById('cards-count-label');
|
||||
|
||||
// Elements - Modal
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
||||
const modalSaveBtn = document.getElementById('modal-save-btn');
|
||||
const modalCardId = document.getElementById('modal-card-id');
|
||||
const modalTerm = document.getElementById('modal-term');
|
||||
const modalTranslation = document.getElementById('modal-translation');
|
||||
const modalContext = document.getElementById('modal-context');
|
||||
const modalTags = document.getElementById('modal-tags');
|
||||
const modalNotes = document.getElementById('modal-notes');
|
||||
|
||||
// Audio
|
||||
const globalAudioPlayer = document.getElementById('global-audio-player');
|
||||
|
||||
// Theme Management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('notes_theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
themeToggle.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('notes_theme', newTheme);
|
||||
themeToggle.textContent = newTheme === 'dark' ? '☀️' : '🌙';
|
||||
});
|
||||
|
||||
// Pronunciation Helper (Audio file or Web Speech API fallback)
|
||||
function playAudio(text, audioUrl) {
|
||||
if (audioUrl) {
|
||||
globalAudioPlayer.src = audioUrl;
|
||||
globalAudioPlayer.play().catch(() => {
|
||||
fallbackSpeechSynthesis(text);
|
||||
});
|
||||
} else {
|
||||
fallbackSpeechSynthesis(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackSpeechSynthesis(text) {
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'en-US';
|
||||
utterance.rate = 0.9;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Handlers
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch('/api/me');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentUser = data.user;
|
||||
showApp();
|
||||
} else {
|
||||
showAuth();
|
||||
}
|
||||
} catch (e) {
|
||||
showAuth();
|
||||
}
|
||||
}
|
||||
|
||||
function showAuth() {
|
||||
authScreen.classList.remove('hidden');
|
||||
appContainer.classList.add('hidden');
|
||||
usernameInput.focus();
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
authScreen.classList.add('hidden');
|
||||
appContainer.classList.remove('hidden');
|
||||
userDisplayName.textContent = currentUser ? currentUser.username : 'user';
|
||||
termInput.focus();
|
||||
loadStats();
|
||||
loadCards();
|
||||
loadTags();
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
authError.classList.add('hidden');
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Вход...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: usernameInput.value.trim(),
|
||||
password: passwordInput.value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
currentUser = data.user;
|
||||
passwordInput.value = '';
|
||||
showApp();
|
||||
} else {
|
||||
authError.textContent = data.detail || 'Ошибка авторизации';
|
||||
authError.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
authError.textContent = 'Ошибка сети. Проверьте подключение к серверу.';
|
||||
authError.classList.remove('hidden');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Войти в систему';
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
} catch (e) {}
|
||||
currentUser = null;
|
||||
showAuth();
|
||||
});
|
||||
|
||||
// Stats
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
statTotal.textContent = data.total_cards;
|
||||
statToday.textContent = `+${data.today_added}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Search & Translate
|
||||
async function performLookup() {
|
||||
const query = termInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
lookupSpinner.classList.remove('hidden');
|
||||
resultCard.classList.add('hidden');
|
||||
translateBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(query)}`);
|
||||
if (resp.status === 401) {
|
||||
showAuth();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error('Ошибка словаря');
|
||||
|
||||
const data = await resp.json();
|
||||
currentLookupData = data;
|
||||
renderResultCard(data);
|
||||
} catch (err) {
|
||||
alert('Не удалось получить перевод. Попробуйте снова или введите перевод вручную.');
|
||||
} finally {
|
||||
lookupSpinner.classList.add('hidden');
|
||||
translateBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResultCard(data) {
|
||||
resTerm.textContent = data.query;
|
||||
|
||||
// Phonetic
|
||||
if (data.phonetic) {
|
||||
resPhonetic.textContent = data.phonetic;
|
||||
resPhonetic.classList.remove('hidden');
|
||||
} else {
|
||||
resPhonetic.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Part of Speech
|
||||
if (data.part_of_speech) {
|
||||
resPos.textContent = data.part_of_speech;
|
||||
resPos.classList.remove('hidden');
|
||||
} else {
|
||||
resPos.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Definitions
|
||||
if (data.definitions && data.definitions.length > 0) {
|
||||
resDefinition.textContent = data.definitions[0];
|
||||
resDefinitionBox.classList.remove('hidden');
|
||||
} else {
|
||||
resDefinitionBox.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Translation field
|
||||
editTranslation.value = data.translation || '';
|
||||
|
||||
// Translation suggestions chips
|
||||
translationSuggestions.innerHTML = '';
|
||||
if (data.translations_list && data.translations_list.length > 1) {
|
||||
data.translations_list.forEach(t => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'suggestion-chip';
|
||||
chip.textContent = t;
|
||||
chip.addEventListener('click', () => {
|
||||
editTranslation.value = t;
|
||||
});
|
||||
translationSuggestions.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
// Context: if user entered a sentence, put it in context field
|
||||
if (data.is_sentence) {
|
||||
editContext.value = data.query;
|
||||
} else {
|
||||
editContext.value = '';
|
||||
}
|
||||
|
||||
// Auto-assign default tags
|
||||
let defaultTags = ['general'];
|
||||
if (data.is_sentence) {
|
||||
defaultTags = ['phrase'];
|
||||
} else if (data.query.includes(' ') && data.part_of_speech && data.part_of_speech.toLowerCase().includes('verb')) {
|
||||
defaultTags = ['phrasal-verb'];
|
||||
}
|
||||
editTags.value = defaultTags.join(', ');
|
||||
editNotes.value = '';
|
||||
|
||||
resultCard.classList.remove('hidden');
|
||||
editTranslation.focus();
|
||||
}
|
||||
|
||||
// Audio Play for Result Card
|
||||
playAudioBtn.addEventListener('click', () => {
|
||||
if (!currentLookupData) return;
|
||||
playAudio(currentLookupData.query, currentLookupData.audio_url);
|
||||
});
|
||||
|
||||
// Quick Tags Selector
|
||||
quickTagsBar.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('tag-chip')) {
|
||||
const tag = e.target.dataset.tag;
|
||||
const currentTags = editTags.value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (!currentTags.includes(tag)) {
|
||||
currentTags.push(tag);
|
||||
editTags.value = currentTags.join(', ');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save Card
|
||||
async function saveCurrentCard() {
|
||||
if (!currentLookupData) return;
|
||||
const term = currentLookupData.query;
|
||||
const translation = editTranslation.value.trim();
|
||||
|
||||
if (!translation) {
|
||||
alert('Пожалуйста, укажите перевод слова.');
|
||||
editTranslation.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
saveCardBtn.disabled = true;
|
||||
saveCardBtn.textContent = 'Сохранение...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
term: term,
|
||||
translation: translation,
|
||||
context_sentence: editContext.value.trim(),
|
||||
phonetic: currentLookupData.phonetic || '',
|
||||
audio_url: currentLookupData.audio_url || '',
|
||||
part_of_speech: currentLookupData.part_of_speech || '',
|
||||
definition_en: currentLookupData.definitions && currentLookupData.definitions.length ? currentLookupData.definitions[0] : '',
|
||||
tags: editTags.value.trim() || 'general',
|
||||
notes: editNotes.value.trim()
|
||||
};
|
||||
|
||||
const resp = await fetch('/api/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
// Success feedback
|
||||
saveCardBtn.textContent = '✅ Сохранено!';
|
||||
setTimeout(() => {
|
||||
resultCard.classList.add('hidden');
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
termInput.focus();
|
||||
saveCardBtn.textContent = '💾 Сохранить в словарь (Ctrl + Enter)';
|
||||
saveCardBtn.disabled = false;
|
||||
}, 600);
|
||||
|
||||
loadCards();
|
||||
loadStats();
|
||||
loadTags();
|
||||
} else {
|
||||
throw new Error('Ошибка сохранения');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Не удалось сохранить карточку. Попробуйте еще раз.');
|
||||
saveCardBtn.disabled = false;
|
||||
saveCardBtn.textContent = '💾 Сохранить в словарь (Ctrl + Enter)';
|
||||
}
|
||||
}
|
||||
|
||||
saveCardBtn.addEventListener('click', saveCurrentCard);
|
||||
|
||||
// Term Input controls
|
||||
termInput.addEventListener('input', () => {
|
||||
if (termInput.value.trim()) {
|
||||
clearSearchBtn.classList.remove('hidden');
|
||||
} else {
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
clearSearchBtn.addEventListener('click', () => {
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
resultCard.classList.add('hidden');
|
||||
termInput.focus();
|
||||
});
|
||||
|
||||
translateBtn.addEventListener('click', performLookup);
|
||||
|
||||
// Global Hotkeys
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+Enter or Cmd+Enter to Save Card
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!resultCard.classList.contains('hidden')) {
|
||||
saveCurrentCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter in search input
|
||||
if (e.key === 'Enter' && document.activeElement === termInput) {
|
||||
e.preventDefault();
|
||||
performLookup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Esc to clear search or close modal
|
||||
if (e.key === 'Escape') {
|
||||
if (!editModal.classList.contains('hidden')) {
|
||||
editModal.classList.add('hidden');
|
||||
} else if (document.activeElement === termInput) {
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
resultCard.classList.add('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Vocabulary Cards & Tags
|
||||
async function loadCards() {
|
||||
try {
|
||||
let url = `/api/cards?limit=150`;
|
||||
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
|
||||
if (selectedTag) url += `&tag=${encodeURIComponent(selectedTag)}`;
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 401) {
|
||||
showAuth();
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
allCards = data.cards;
|
||||
cardsCountLabel.textContent = `(${data.total} записей)`;
|
||||
renderCards(allCards);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderCards(cards) {
|
||||
cardsList.innerHTML = '';
|
||||
if (!cards || cards.length === 0) {
|
||||
emptyState.classList.remove('hidden');
|
||||
cardsList.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
cards.forEach(card => {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = 'vocab-card';
|
||||
|
||||
// Format date
|
||||
const cardDate = new Date(card.created_at);
|
||||
const dateStr = cardDate.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// Format tags
|
||||
const tagsList = (card.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const tagsHtml = tagsList.map(t => `<span class="card-tag">#${escapeHtml(t)}</span>`).join('');
|
||||
|
||||
// Highlight context
|
||||
let contextHtml = '';
|
||||
if (card.context_sentence) {
|
||||
let formattedContext = escapeHtml(card.context_sentence);
|
||||
// Highlight term occurrence in context
|
||||
const regex = new RegExp(`(${escapeRegExp(card.term)})`, 'gi');
|
||||
formattedContext = formattedContext.replace(regex, '<mark>$1</mark>');
|
||||
contextHtml = `<div class="card-context">“${formattedContext}”</div>`;
|
||||
}
|
||||
|
||||
// Notes
|
||||
let notesHtml = '';
|
||||
if (card.notes) {
|
||||
notesHtml = `<span class="card-notes" title="Заметка">💡 ${escapeHtml(card.notes)}</span>`;
|
||||
}
|
||||
|
||||
cardEl.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div class="card-term-group">
|
||||
<span class="card-term">${escapeHtml(card.term)}</span>
|
||||
${card.phonetic ? `<span class="card-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
|
||||
${card.part_of_speech ? `<span class="card-pos">${escapeHtml(card.part_of_speech)}</span>` : ''}
|
||||
<button class="icon-btn play-card-audio-btn" title="Озвучить" data-term="${escapeHtml(card.term)}" data-audio="${escapeHtml(card.audio_url || '')}">🔊</button>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn edit-card-btn" title="Редактировать" data-id="${card.id}">✏️</button>
|
||||
<button class="icon-btn delete-card-btn" title="Удалить" data-id="${card.id}">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-translation">${escapeHtml(card.translation)}</div>
|
||||
${contextHtml}
|
||||
|
||||
<div class="card-bottom">
|
||||
<div class="card-tags">${tagsHtml}</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
${notesHtml}
|
||||
<span class="card-date">${dateStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Card Listeners
|
||||
cardEl.querySelector('.play-card-audio-btn').addEventListener('click', (e) => {
|
||||
const btn = e.currentTarget;
|
||||
playAudio(btn.dataset.term, btn.dataset.audio);
|
||||
});
|
||||
|
||||
cardEl.querySelector('.edit-card-btn').addEventListener('click', () => {
|
||||
openEditModal(card);
|
||||
});
|
||||
|
||||
cardEl.querySelector('.delete-card-btn').addEventListener('click', async () => {
|
||||
if (confirm(`Удалить слово "${card.term}" из словаря?`)) {
|
||||
await deleteCard(card.id);
|
||||
}
|
||||
});
|
||||
|
||||
cardsList.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteCard(cardId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/cards/${cardId}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
loadCards();
|
||||
loadStats();
|
||||
loadTags();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка удаления карточки');
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Edit
|
||||
function openEditModal(card) {
|
||||
modalCardId.value = card.id;
|
||||
modalTerm.value = card.term;
|
||||
modalTranslation.value = card.translation;
|
||||
modalContext.value = card.context_sentence || '';
|
||||
modalTags.value = card.tags || '';
|
||||
modalNotes.value = card.notes || '';
|
||||
editModal.classList.remove('hidden');
|
||||
modalTranslation.focus();
|
||||
}
|
||||
|
||||
closeModalBtn.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
modalCancelBtn.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
|
||||
modalSaveBtn.addEventListener('click', async () => {
|
||||
const id = modalCardId.value;
|
||||
try {
|
||||
const payload = {
|
||||
term: modalTerm.value.trim(),
|
||||
translation: modalTranslation.value.trim(),
|
||||
context_sentence: modalContext.value.trim(),
|
||||
tags: modalTags.value.trim(),
|
||||
notes: modalNotes.value.trim()
|
||||
};
|
||||
const resp = await fetch(`/api/cards/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) {
|
||||
editModal.classList.add('hidden');
|
||||
loadCards();
|
||||
loadTags();
|
||||
} else {
|
||||
alert('Не удалось обновить карточку.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка при сохранении.');
|
||||
}
|
||||
});
|
||||
|
||||
// Tag Filter Bar
|
||||
async function loadTags() {
|
||||
try {
|
||||
const resp = await fetch('/api/tags');
|
||||
if (resp.ok) {
|
||||
const tags = await resp.json();
|
||||
renderTagsFilter(tags);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderTagsFilter(tags) {
|
||||
tagsFilterBar.innerHTML = '';
|
||||
|
||||
// "All tags" button
|
||||
const allBtn = document.createElement('button');
|
||||
allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`;
|
||||
allBtn.textContent = 'Все теги';
|
||||
allBtn.addEventListener('click', () => {
|
||||
selectedTag = '';
|
||||
document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active'));
|
||||
allBtn.classList.add('active');
|
||||
loadCards();
|
||||
});
|
||||
tagsFilterBar.appendChild(allBtn);
|
||||
|
||||
tags.forEach(({ tag, count }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `filter-chip ${selectedTag === tag ? 'active' : ''}`;
|
||||
btn.textContent = `#${tag} (${count})`;
|
||||
btn.addEventListener('click', () => {
|
||||
if (selectedTag === tag) {
|
||||
selectedTag = '';
|
||||
btn.classList.remove('active');
|
||||
allBtn.classList.add('active');
|
||||
} else {
|
||||
selectedTag = tag;
|
||||
document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
loadCards();
|
||||
});
|
||||
tagsFilterBar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// Mini search filter
|
||||
let debounceTimer = null;
|
||||
filterInput.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchQuery = filterInput.value.trim();
|
||||
loadCards();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
initTheme();
|
||||
checkAuth();
|
||||
});
|
||||
Reference in new issue
Block a user