Files
secrets/crates/secrets-mcp/templates/entries.html

1308 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — 条目</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1480px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.folder-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.folder-tab {
text-decoration: none;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
background: var(--bg);
}
.folder-tab:hover { color: var(--text); border-color: var(--text-muted); }
.folder-tab.active {
background: rgba(88,166,255,0.12);
border-color: rgba(88,166,255,0.35);
color: var(--text);
}
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px;
}
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.filter-field input {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
}
.filter-field select {
background-color: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 12px 12px;
border: 1px solid var(--border); border-radius: 6px;
color: var(--text);
padding: 8px 2.8rem 8px 10px;
font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.filter-field input:focus,
.filter-field select:focus { border-color: var(--accent); }
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.btn-filter {
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-filter:hover { background: var(--accent-hover); }
.btn-clear {
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { background: var(--surface2); color: var(--text); }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
.table-wrap {
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
}
table {
width: 100%;
min-width: 960px;
border-collapse: separate;
border-spacing: 0;
}
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 2;
background: var(--surface);
}
td { font-size: 13px; line-height: 1.45; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; }
.col-type { min-width: 108px; width: 1%; }
.col-name { min-width: 180px; max-width: 260px; }
.col-tags { min-width: 160px; max-width: 220px; }
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; }
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
.col-actions { min-width: 132px; width: 1%; }
.cell-name, .cell-tags-val {
overflow-wrap: anywhere;
word-break: break-word;
}
.cell-notes { min-width: 260px; max-width: 360px; }
.notes-scroll {
max-height: 120px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 360px; max-height: 120px; overflow: auto;
}
.col-actions { white-space: nowrap; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
.secret-chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
background: var(--surface2);
font-family: 'JetBrains Mono', monospace;
max-width: 100%;
min-width: 0;
}
.secret-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secret-type {
color: var(--text-muted);
border-left: 1px solid var(--border);
padding-left: 6px;
}
.btn-unlink-secret {
border: none;
background: transparent;
color: #f85149;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
}
.secret-edit-row {
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 0;
}
.secret-edit-main {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.secret-name-input {
width: 100%;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
}
.secret-name-input:focus {
border-color: var(--accent);
}
.secret-name-input.invalid {
border-color: #f85149;
}
.secret-name-input.valid {
border-color: #3fb950;
}
.secret-type-select {
width: 100%;
min-width: 0;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
cursor: pointer;
}
.secret-type-select:focus {
border-color: var(--accent);
}
.secret-type-select.invalid {
border-color: #f85149;
}
.btn-unlink-secret {
border: none;
background: transparent;
color: #f85149;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 4px;
}
.secret-name-status {
font-size: 11px;
line-height: 1.3;
margin-top: 2px;
color: var(--text-muted);
}
.secret-name-status:empty {
display: none;
}
.secret-name-status.checking {
color: var(--accent);
}
.secret-name-status.error {
color: #f85149;
}
.secret-name-status.success {
color: #3fb950;
}
.btn-row {
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-overlay[hidden] { display: none !important; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
}
.modal.modal-wide {
max-width: 800px;
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.modal-field { margin-bottom: 12px; }
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.modal-field input, .modal-field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none;
}
.modal-field textarea { min-height: 72px; resize: vertical; }
.modal-field textarea.metadata-edit { min-height: 140px; }
.modal-readonly-value {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text-muted); padding: 8px 10px; font-size: 13px;
font-family: 'JetBrains Mono', monospace;
}
.modal-secrets .secret-list { display: flex; flex-direction: column; gap: 10px; }
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
.modal-error.visible { display: block; }
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
.btn-modal.primary:hover { background: var(--accent-hover); }
.btn-modal.danger { border-color: #f85149; color: #f85149; }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.table-wrap { max-height: none; border: none; background: transparent; }
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; max-width: none; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
content: attr(data-label);
}
.detail, .notes-scroll, .secret-list { max-width: none; }
}
.pagination {
display: flex; align-items: center; gap: 8px; margin-top: 20px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { background: var(--surface2); }
.page-btn-disabled {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link active" data-i18n="navEntries">条目</a>
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title" data-i18n="entriesTitle">我的条目</div>
<div class="folder-tabs">
{% for tab in folder_tabs %}
<a href="{{ tab.href }}" class="folder-tab{% if tab.active %} active{% endif %}" {% if loop.first %}data-all-tab="1" data-count="{{ tab.count }}"{% endif %}>
{{ tab.name }} ({{ tab.count }})
</a>
{% endfor %}
</div>
<form class="filter-bar" method="get" action="/entries">
{% if !filter_folder.is_empty() %}
<input type="hidden" name="folder" value="{{ filter_folder }}">
{% endif %}
<div class="filter-field">
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-type" data-i18n="filterTypeLabel">类型</label>
<select id="filter-type" name="type" onchange="this.form.requestSubmit();">
<option value="" data-i18n="filterTypeAll">全部</option>
{% for t in type_options %}
<option value="{{ t }}"{% if filter_type.as_str() == t.as_str() %} selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="filter-actions">
<button type="submit" class="btn-filter" data-i18n="filterSubmit">筛选</button>
<a href="/entries" class="btn-clear" data-i18n="filterClear">清空</a>
</div>
</form>
{% if entries.is_empty() %}
<div class="empty" data-i18n="emptyEntries">暂无条目。</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="colName">名称</th>
<th data-i18n="colType">类型</th>
<th data-i18n="colNotes">备注</th>
<th data-i18n="colTags">标签</th>
<th data-i18n="colSecrets">密文</th>
<th data-i18n="colActions">操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-updated-at="{{ entry.updated_at_iso }}">
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
<td class="col-secrets" data-label="密文">
<div class="secret-list">
{% for s in entry.secrets %}
<span class="secret-chip">
<span class="secret-name" title="{{ s.name }}">{{ s.name }}</span>
<span class="secret-type">{{ s.secret_type }}</span>
<button type="button" class="btn-unlink-secret" data-secret-id="{{ s.id }}" data-secret-name="{{ s.name }}" title="解除关联">×</button>
</span>
{% endfor %}
</div>
</td>
<td class="col-actions" data-label="操作">
<div class="row-actions">
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑</button>
<button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_count > 0 %}
<div class="pagination">
{% if current_page > 1 %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
{% endif %}
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
{% if current_page < total_pages %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</section>
</main>
</div>
</div>
<div id="edit-overlay" class="modal-overlay" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目</div>
<div id="edit-error" class="modal-error"></div>
<div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-folder" data-i18n="modalFolder">文件夹</label><input id="edit-folder" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-notes" data-i18n="modalNotes">备注</label><textarea id="edit-notes"></textarea></div>
<div class="modal-field"><label for="edit-tags" data-i18n="modalTags">标签(逗号分隔)</label><input id="edit-tags" type="text" autocomplete="off"></div>
<div class="modal-field"><label data-i18n="modalUpdated">更新</label><div id="edit-updated-at" class="modal-readonly-value" aria-live="polite"></div></div>
<div class="modal-field"><label for="edit-metadata" data-i18n="modalMetadata">元数据JSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
<div class="modal-field modal-secrets"><label data-i18n="modalSecrets">密文</label><div id="edit-secrets-list" class="secret-list"></div></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="edit-cancel" data-i18n="modalCancel">取消</button>
<button type="button" class="btn-modal primary" id="edit-save" data-i18n="modalSave">保存</button>
</div>
</div>
</div>
<div id="delete-overlay" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-title">
<div class="modal-title" id="delete-title" data-i18n="deleteTitle">确认删除</div>
<div id="delete-error" class="modal-error"></div>
<div class="modal-field">
<div id="delete-message" style="font-size:14px;line-height:1.6;color:var(--text)"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="delete-cancel" data-i18n="modalCancel">取消</button>
<button type="button" class="btn-modal danger" id="delete-confirm" data-i18n="deleteConfirm">删除</button>
</div>
</div>
</div>
<script src="/static/i18n.js"></script>
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
<script>
var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-options').textContent);
(function () {
I18N_PAGE = {
'zh-CN': {
pageTitle: 'Secrets — 条目',
entriesTitle: '我的条目',
allTab: '全部',
filterNameLabel: '名称',
filterNamePlaceholder: '输入关键字',
filterTypeLabel: '类型',
filterTypeAll: '全部',
filterSubmit: '筛选',
filterClear: '清空',
emptyEntries: '暂无条目。',
colName: '名称',
colType: '类型',
colNotes: '备注',
colTags: '标签',
colSecrets: '密文',
colActions: '操作',
rowEdit: '编辑',
rowDelete: '删除',
modalTitle: '编辑条目',
modalName: '名称',
modalType: '类型',
modalFolder: '文件夹',
modalNotes: '备注',
modalTags: '标签(逗号分隔)',
modalUpdated: '更新',
modalMetadata: '元数据JSON 对象)',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '保存',
deleteTitle: '确认删除',
deleteConfirm: '删除',
deleteMessage: '确定删除条目「{name}」?此操作不可撤销。',
mobileLabelName: '名称',
mobileLabelType: '类型',
mobileLabelNotes: '备注',
mobileLabelTags: '标签',
mobileLabelSecrets: '密文',
mobileLabelActions: '操作',
errInvalidJson: '元数据不是合法 JSON',
errMetadataObject: '元数据必须是 JSON 对象',
confirmDeleteEntry: '确定删除条目「{name}」?',
confirmUnlinkSecret: '确定解除密文关联「{name}」?',
unlinkTitle: '解除关联',
renameSecretTitle: '重命名密文',
renameSecretPlaceholder: '输入新名称',
errRenameSecret: '重命名失败:{error}',
checkingSecretName: '检查中...',
secretNameAvailable: '名称可用',
secretNameTaken: '该名称已被使用',
prevPage: '上一页',
nextPage: '下一页',
},
'zh-TW': {
pageTitle: 'Secrets — 條目',
entriesTitle: '我的條目',
allTab: '全部',
filterNameLabel: '名稱',
filterNamePlaceholder: '輸入關鍵字',
filterTypeLabel: '類型',
filterTypeAll: '全部',
filterSubmit: '篩選',
filterClear: '清除',
emptyEntries: '暫無條目。',
colName: '名稱',
colType: '類型',
colNotes: '備註',
colTags: '標籤',
colSecrets: '密文',
colActions: '操作',
rowEdit: '編輯',
rowDelete: '刪除',
modalTitle: '編輯條目',
modalName: '名稱',
modalType: '類型',
modalFolder: '資料夾',
modalNotes: '備註',
modalTags: '標籤(逗號分隔)',
modalUpdated: '更新時間',
modalMetadata: '中繼資料JSON 物件)',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '儲存',
deleteTitle: '確認刪除',
deleteConfirm: '刪除',
deleteMessage: '確定刪除條目「{name}」?此操作不可復原。',
mobileLabelName: '名稱',
mobileLabelType: '類型',
mobileLabelNotes: '備註',
mobileLabelTags: '標籤',
mobileLabelSecrets: '密文',
mobileLabelActions: '操作',
errInvalidJson: '中繼資料不是合法 JSON',
errMetadataObject: '中繼資料必須是 JSON 物件',
confirmDeleteEntry: '確定刪除條目「{name}」?',
confirmUnlinkSecret: '確定解除密文關聯「{name}」?',
unlinkTitle: '解除關聯',
renameSecretTitle: '重新命名密文',
renameSecretPlaceholder: '輸入新名稱',
errRenameSecret: '重新命名失敗:{error}',
checkingSecretName: '檢查中...',
secretNameAvailable: '名稱可用',
secretNameTaken: '該名稱已被使用',
secretNameInvalid: '名稱不合法',
secretNameCheckError: '校驗失敗,請重試',
secretNameFixBeforeSave: '請先修復密文名稱校驗問題後再儲存',
secretTypePlaceholder: '選擇類型',
secretTypeInvalid: '類型不能為空',
prevPage: '上一頁',
nextPage: '下一頁',
},
en: {
pageTitle: 'Secrets — Entries',
entriesTitle: 'My entries',
allTab: 'All',
filterNameLabel: 'Name',
filterNamePlaceholder: 'Enter keywords',
filterTypeLabel: 'Type',
filterTypeAll: 'All',
filterSubmit: 'Filter',
filterClear: 'Clear',
emptyEntries: 'No entries.',
colName: 'Name',
colType: 'Type',
colNotes: 'Notes',
colTags: 'Tags',
colSecrets: 'Secrets',
colActions: 'Actions',
rowEdit: 'Edit',
rowDelete: 'Delete',
modalTitle: 'Edit entry',
modalName: 'Name',
modalType: 'Type',
modalFolder: 'Folder',
modalNotes: 'Notes',
modalTags: 'Tags (comma separated)',
modalUpdated: 'Updated',
modalMetadata: 'Metadata (JSON object)',
modalSecrets: 'Secrets',
modalCancel: 'Cancel',
modalSave: 'Save',
deleteTitle: 'Confirm Delete',
deleteConfirm: 'Delete',
deleteMessage: 'Are you sure you want to delete entry "{name}"? This action cannot be undone.',
mobileLabelName: 'Name',
mobileLabelType: 'Type',
mobileLabelNotes: 'Notes',
mobileLabelTags: 'Tags',
mobileLabelSecrets: 'Secrets',
mobileLabelActions: 'Actions',
errInvalidJson: 'Metadata is not valid JSON',
errMetadataObject: 'Metadata must be a JSON object',
confirmDeleteEntry: 'Delete entry "{name}"?',
confirmUnlinkSecret: 'Unlink secret "{name}"?',
unlinkTitle: 'Unlink',
renameSecretTitle: 'Rename secret',
renameSecretPlaceholder: 'Enter new name',
errRenameSecret: 'Failed to rename: {error}',
checkingSecretName: 'Checking...',
secretNameAvailable: 'Name available',
secretNameTaken: 'Name already taken',
secretNameInvalid: 'Invalid name',
secretNameCheckError: 'Validation failed, please retry',
secretNameFixBeforeSave: 'Fix secret name validation errors before saving',
secretTypePlaceholder: 'Select type',
secretTypeInvalid: 'Type cannot be empty',
prevPage: 'Previous',
nextPage: 'Next'
}
};
window.applyPageLang = function () {
var allTab = document.querySelector('.folder-tab[data-all-tab="1"]');
if (allTab) {
var count = allTab.getAttribute('data-count') || '0';
allTab.textContent = t('allTab') + ' (' + count + ')';
}
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
var map = {
'.col-name': 'mobileLabelName',
'.col-type': 'mobileLabelType',
'.col-notes': 'mobileLabelNotes',
'.col-tags': 'mobileLabelTags',
'.col-secrets': 'mobileLabelSecrets',
'.col-actions': 'mobileLabelActions'
};
Object.keys(map).forEach(function (sel) {
var td = tr.querySelector(sel);
if (td) td.setAttribute('data-label', t(map[sel]));
});
});
editSecretsList.querySelectorAll('.btn-unlink-secret').forEach(function (btn) {
btn.title = t('unlinkTitle');
});
editSecretsList.querySelectorAll('.secret-name-input').forEach(function (input) {
input.placeholder = t('renameSecretPlaceholder');
});
document.querySelectorAll('.table-wrap .btn-unlink-secret').forEach(function (btn) {
btn.title = t('unlinkTitle');
});
};
var editOverlay = document.getElementById('edit-overlay');
var editError = document.getElementById('edit-error');
var editFolder = document.getElementById('edit-folder');
var editType = document.getElementById('edit-type');
var editName = document.getElementById('edit-name');
var editNotes = document.getElementById('edit-notes');
var editTags = document.getElementById('edit-tags');
var editMetadata = document.getElementById('edit-metadata');
var editUpdatedAt = document.getElementById('edit-updated-at');
var editSecretsList = document.getElementById('edit-secrets-list');
var deleteOverlay = document.getElementById('delete-overlay');
var deleteError = document.getElementById('delete-error');
var deleteMessage = document.getElementById('delete-message');
var currentEntryId = null;
var pendingDeleteId = null;
function showEditErr(msg) {
editError.textContent = msg || '';
editError.classList.toggle('visible', !!msg);
}
function formatLocalTime(raw) {
if (!raw) return '—';
var d = new Date(raw);
if (!d || isNaN(d.getTime())) return raw;
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
}
function renderEditSecrets(secrets) {
editSecretsList.innerHTML = '';
if (!Array.isArray(secrets) || secrets.length === 0) return;
secrets.forEach(function (s) {
var row = document.createElement('div');
row.className = 'secret-edit-row';
row.setAttribute('data-secret-id', s.id || '');
row.setAttribute('data-secret-name', s.name || '');
row.setAttribute('data-secret-type', s.secret_type || '');
var main = document.createElement('div');
main.className = 'secret-edit-main';
var input = document.createElement('input');
input.type = 'text';
input.className = 'secret-name-input';
input.value = s.name || '';
input.placeholder = t('renameSecretPlaceholder');
input.setAttribute('data-original-name', s.name || '');
var typeSelect = document.createElement('select');
typeSelect.className = 'secret-type-select';
var currentType = s.secret_type || 'text';
var hasCurrentInOptions = SECRET_TYPE_OPTIONS.indexOf(currentType) !== -1;
SECRET_TYPE_OPTIONS.forEach(function (opt) {
var option = document.createElement('option');
option.value = opt;
option.textContent = opt;
if (opt === currentType) option.selected = true;
typeSelect.appendChild(option);
});
if (!hasCurrentInOptions && currentType) {
var fallback = document.createElement('option');
fallback.value = currentType;
fallback.textContent = currentType;
fallback.selected = true;
typeSelect.appendChild(fallback);
}
var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-unlink-secret';
unlinkBtn.setAttribute('data-secret-id', s.id || '');
unlinkBtn.setAttribute('data-secret-name', s.name || '');
unlinkBtn.title = t('unlinkTitle');
unlinkBtn.textContent = '\u00d7';
main.appendChild(typeSelect);
main.appendChild(input);
main.appendChild(unlinkBtn);
var status = document.createElement('div');
status.className = 'secret-name-status';
status.setAttribute('data-status', 'idle');
row.appendChild(main);
row.appendChild(status);
editSecretsList.appendChild(row);
bindSecretValidation(row, s.id, s.name, s.secret_type || '');
});
}
function bindSecretValidation(row, secretId, originalName, originalType) {
var input = row.querySelector('.secret-name-input');
var typeSelect = row.querySelector('.secret-type-select');
var status = row.querySelector('.secret-name-status');
var debounceTimer = null;
var currentCheck = null;
var lastValidatedName = originalName;
function setStatus(text, type) {
status.textContent = text || '';
status.className = 'secret-name-status';
if (type) status.classList.add(type);
row.setAttribute('data-validation-state', type || 'idle');
}
function setLastValidatedName(name) {
row.setAttribute('data-last-validated-name', name || '');
}
function invalidateValidationState() {
currentCheck = null;
lastValidatedName = null;
setLastValidatedName('');
setStatus('', 'idle');
input.className = 'secret-name-input';
}
function checkNameAvailability(name) {
if (!name || name === originalName) {
setStatus('', 'idle');
input.className = 'secret-name-input';
setLastValidatedName(originalName);
return Promise.resolve(false);
}
if (name.length > 256) {
setStatus(t('secretNameInvalid'), 'error');
input.className = 'secret-name-input invalid';
setLastValidatedName('');
return Promise.resolve(false);
}
setStatus(t('checkingSecretName'), 'checking');
input.className = 'secret-name-input';
var checkId = Date.now();
currentCheck = checkId;
var params = new URLSearchParams();
params.set('name', name);
params.set('exclude_secret_id', secretId);
return fetch('/api/secrets/check-name?' + params.toString(), {
credentials: 'same-origin'
}).then(function (r) {
return r.json();
}).then(function (data) {
if (currentCheck !== checkId) return false;
if (data.ok && data.available) {
setStatus(t('secretNameAvailable'), 'success');
input.className = 'secret-name-input valid';
lastValidatedName = name;
setLastValidatedName(name);
return true;
} else {
setStatus(data.error || t('secretNameTaken'), 'error');
input.className = 'secret-name-input invalid';
setLastValidatedName('');
return false;
}
}).catch(function () {
if (currentCheck !== checkId) return false;
setStatus(t('secretNameCheckError'), 'error');
input.className = 'secret-name-input invalid';
setLastValidatedName('');
return false;
});
}
row.secretValidateNow = function () {
return checkNameAvailability(input.value.trim());
};
input.addEventListener('input', function () {
var newName = input.value.trim();
if (debounceTimer) clearTimeout(debounceTimer);
invalidateValidationState();
debounceTimer = setTimeout(function () {
checkNameAvailability(newName);
}, 300);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
checkNameAvailability(input.value.trim());
} else if (e.key === 'Escape') {
input.value = originalName;
invalidateValidationState();
}
});
}
function openEdit(tr) {
var id = tr.getAttribute('data-entry-id');
if (!id) return;
currentEntryId = id;
showEditErr('');
editFolder.value = tr.getAttribute('data-entry-folder') || '';
editType.value = tr.querySelector('.cell-type') ? tr.querySelector('.cell-type').textContent.trim() : '';
editName.value = tr.querySelector('.cell-name') ? tr.querySelector('.cell-name').textContent.trim() : '';
editNotes.value = tr.querySelector('.cell-notes-val') ? tr.querySelector('.cell-notes-val').textContent : '';
var tagsText = tr.querySelector('.cell-tags-val') ? tr.querySelector('.cell-tags-val').textContent.trim() : '';
editTags.value = tagsText;
var rawUpdated = tr.getAttribute('data-updated-at');
editUpdatedAt.textContent = formatLocalTime(rawUpdated);
editUpdatedAt.title = rawUpdated ? rawUpdated + ' (UTC)' : '';
var md = tr.getAttribute('data-entry-metadata') || '{}';
try {
var mobj = JSON.parse(md);
editMetadata.value = JSON.stringify(mobj, null, 2);
} catch (err) {
editMetadata.value = md;
}
var sj = tr.getAttribute('data-entry-secrets') || '[]';
try {
renderEditSecrets(JSON.parse(sj));
} catch (err) {
renderEditSecrets([]);
}
editOverlay.hidden = false;
}
function closeEdit() {
editOverlay.hidden = true;
currentEntryId = null;
showEditErr('');
editSecretsList.innerHTML = '';
editUpdatedAt.textContent = '';
editUpdatedAt.title = '';
}
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
editOverlay.addEventListener('click', function (e) {
if (e.target === editOverlay) closeEdit();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
});
function showDeleteErr(msg) {
deleteError.textContent = msg || '';
deleteError.classList.toggle('visible', !!msg);
}
function openDelete(id, name) {
pendingDeleteId = id;
deleteMessage.textContent = tf('deleteMessage', { name: name });
showDeleteErr('');
deleteOverlay.hidden = false;
}
function closeDelete() {
deleteOverlay.hidden = true;
pendingDeleteId = null;
showDeleteErr('');
}
function refreshListAfterSave(entryId, body, secretRows) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) { window.location.reload(); return; }
var nameCell = tr.querySelector('.cell-name');
if (nameCell) nameCell.textContent = body.name;
var typeCell = tr.querySelector('.cell-type');
if (typeCell) typeCell.textContent = body.type;
var notesCell = tr.querySelector('.cell-notes-val');
if (notesCell) {
if (body.notes) { notesCell.textContent = body.notes; }
else { var notesWrap = tr.querySelector('.cell-notes'); if (notesWrap) notesWrap.innerHTML = ''; }
}
var tagsCell = tr.querySelector('.cell-tags-val');
if (tagsCell) tagsCell.textContent = body.tags.join(', ');
var secretsList = tr.querySelector('.secret-list');
if (secretsList) {
secretsList.innerHTML = '';
secretRows.forEach(function (info) {
var chip = document.createElement('span');
chip.className = 'secret-chip';
var nameSpan = document.createElement('span');
nameSpan.className = 'secret-name';
nameSpan.textContent = info.newName;
nameSpan.title = info.newName;
var typeSpan = document.createElement('span');
typeSpan.className = 'secret-type';
typeSpan.textContent = info.newType || 'text';
var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-unlink-secret';
unlinkBtn.setAttribute('data-secret-id', info.secretId);
unlinkBtn.setAttribute('data-secret-name', info.newName);
unlinkBtn.title = t('unlinkTitle');
unlinkBtn.textContent = '\u00d7';
chip.appendChild(nameSpan);
chip.appendChild(typeSpan);
chip.appendChild(unlinkBtn);
secretsList.appendChild(chip);
});
}
tr.setAttribute('data-entry-folder', body.folder);
tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata));
var updatedSecrets = secretRows.map(function (info) {
return { id: info.secretId, name: info.newName, secret_type: info.newType || 'text' };
});
tr.setAttribute('data-entry-secrets', JSON.stringify(updatedSecrets));
}
function refreshListAfterDelete(entryId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
var folder = tr ? tr.getAttribute('data-entry-folder') : null;
if (tr) tr.remove();
var tbody = document.querySelector('table tbody');
if (tbody && !tbody.querySelector('tr[data-entry-id]')) {
var card = document.querySelector('.card');
if (card) {
var tableWrap = card.querySelector('.table-wrap');
if (tableWrap) tableWrap.remove();
var existingEmpty = card.querySelector('.empty');
if (!existingEmpty) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'empty';
emptyDiv.setAttribute('data-i18n', 'emptyEntries');
emptyDiv.textContent = t('emptyEntries');
var filterBar = card.querySelector('.filter-bar');
if (filterBar) { card.insertBefore(emptyDiv, filterBar.nextSibling); }
else { card.appendChild(emptyDiv); }
}
}
}
var allTab = document.querySelector('.folder-tab[data-all-tab="1"]');
if (allTab) {
var count = parseInt(allTab.getAttribute('data-count') || '0', 10);
if (count > 0) {
count -= 1;
allTab.setAttribute('data-count', String(count));
allTab.textContent = t('allTab') + ' (' + count + ')';
}
}
if (folder) {
document.querySelectorAll('.folder-tab:not([data-all-tab])').forEach(function (tab) {
if (tab.textContent.trim().indexOf(folder) === 0) {
var m = tab.textContent.match(/\((\d+)\)/);
if (m) {
var c = parseInt(m[1], 10);
if (c > 0) {
c -= 1;
tab.textContent = folder + ' (' + c + ')';
}
}
}
});
}
}
function refreshListAfterUnlink(entryId, secretId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var chip = tr.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
if (chip && chip.parentElement) chip.parentElement.remove();
var secrets = tr.getAttribute('data-entry-secrets');
try {
var arr = JSON.parse(secrets);
arr = arr.filter(function (s) { return s.id !== secretId; });
tr.setAttribute('data-entry-secrets', JSON.stringify(arr));
} catch (e) {}
}
document.getElementById('delete-cancel').addEventListener('click', closeDelete);
deleteOverlay.addEventListener('click', function (e) {
if (e.target === deleteOverlay) closeDelete();
});
document.getElementById('delete-confirm').addEventListener('click', function () {
if (!pendingDeleteId) return;
fetch('/api/entries/' + encodeURIComponent(pendingDeleteId), { method: 'DELETE', credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
})
.then(function () {
var deletedId = pendingDeleteId;
closeDelete();
refreshListAfterDelete(deletedId);
})
.catch(function (e) { showDeleteErr(e.message || String(e)); });
});
document.getElementById('edit-save').addEventListener('click', function () {
if (!currentEntryId) return;
var meta;
try {
meta = JSON.parse(editMetadata.value);
} catch (err) {
showEditErr(t('errInvalidJson'));
return;
}
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
showEditErr(t('errMetadataObject'));
return;
}
var tags = editTags.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var body = {
folder: editFolder.value,
type: editType.value,
name: editName.value.trim(),
notes: editNotes.value,
tags: tags,
metadata: meta
};
var secretRows = Array.from(editSecretsList.querySelectorAll('.secret-edit-row')).map(function (row) {
var input = row.querySelector('.secret-name-input');
var typeSelect = row.querySelector('.secret-type-select');
return {
row: row,
input: input,
typeSelect: typeSelect,
secretId: row.getAttribute('data-secret-id') || '',
originalName: input ? (input.getAttribute('data-original-name') || '') : '',
newName: input ? input.value.trim() : '',
originalType: row.getAttribute('data-secret-type') || '',
newType: typeSelect ? typeSelect.value : ''
};
});
showEditErr('');
Promise.all(secretRows.map(function (info) {
if (!info.secretId || info.newName === info.originalName) return Promise.resolve(true);
if (typeof info.row.secretValidateNow === 'function') {
return info.row.secretValidateNow();
}
return Promise.resolve(false);
})).then(function () {
var invalidSecret = secretRows.find(function (info) {
if (!info.secretId || info.newName === info.originalName) return false;
var state = info.row.getAttribute('data-validation-state') || 'idle';
var lastValidated = info.row.getAttribute('data-last-validated-name') || '';
return state !== 'success' || lastValidated !== info.newName;
});
if (invalidSecret) {
if (invalidSecret.input) invalidSecret.input.focus();
throw new Error(t('secretNameFixBeforeSave'));
}
return fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
});
}).then(function () {
var changedSecrets = secretRows.filter(function (info) {
return info.secretId && (info.newName !== info.originalName || info.newType !== info.originalType);
});
return Promise.all(changedSecrets.map(function (info) {
var patchBody = {};
if (info.newName !== info.originalName) patchBody.name = info.newName;
if (info.newType !== info.originalType) patchBody.type = info.newType;
return fetch('/api/secrets/' + encodeURIComponent(info.secretId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(patchBody)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).catch(function (err) {
var msg = err.message || String(err);
var status = info.row.querySelector('.secret-name-status');
info.row.setAttribute('data-validation-state', 'error');
info.row.setAttribute('data-last-validated-name', '');
if (status) {
status.textContent = msg;
status.className = 'secret-name-status error';
}
if (info.input) {
info.input.className = 'secret-name-input invalid';
}
if (info.typeSelect) {
info.typeSelect.className = 'secret-type-select invalid';
}
if (info.input) info.input.focus();
throw new Error(tf('errRenameSecret', { error: msg }));
});
}));
}).then(function () {
closeEdit();
refreshListAfterSave(currentEntryId, body, secretRows);
}).catch(function (e) {
showEditErr(e.message || String(e));
});
});
var tableWrap = document.querySelector('.table-wrap');
if (tableWrap) {
tableWrap.addEventListener('click', function (e) {
var btn = e.target.closest('.btn-unlink-secret');
if (!btn || !tableWrap.contains(btn)) return;
var tr = btn.closest('tr[data-entry-id]');
var entryId = tr && tr.getAttribute('data-entry-id');
var secretId = btn.getAttribute('data-secret-id');
var secretName = btn.getAttribute('data-secret-name') || '';
if (!entryId || !secretId) return;
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE',
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
refreshListAfterUnlink(entryId, secretId);
}).catch(function (err) {
alert(err.message || String(err));
});
});
}
editSecretsList.addEventListener('click', function (e) {
var btn = e.target.closest('.btn-unlink-secret');
if (!btn || !editSecretsList.contains(btn)) return;
var entryId = currentEntryId;
var secretId = btn.getAttribute('data-secret-id');
var secretName = btn.getAttribute('data-secret-name') || '';
if (!entryId || !secretId) return;
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE',
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
btn.closest('.secret-edit-row').remove();
var tableRow = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
if (chip && chip.parentElement) chip.parentElement.remove();
}
}).catch(function (err) {
alert(err.message || String(err));
});
});
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
tr.querySelector('.btn-del').addEventListener('click', function () {
var id = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var name = nameEl ? nameEl.textContent.trim() : '';
if (!id) return;
openDelete(id, name);
});
});
applyLang();
})();
</script>
</body>
</html>