feat: add zotero import modal shell

This commit is contained in:
Saberlve 2026-04-15 12:16:31 +08:00
parent f7554b889a
commit 6a2250ef1a

View File

@ -0,0 +1,868 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zotero KB</title>
<style>
:root {
color-scheme: light;
--bg: #f2eee5;
--panel: rgba(255, 250, 242, 0.9);
--ink: #1d221b;
--muted: #56604f;
--line: #d7cdbd;
--accent: #2c6e49;
--accent-strong: #1e4d33;
--surface: #fffdf8;
--danger: #8b3a2a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(44, 110, 73, 0.14), transparent 30%),
linear-gradient(180deg, #f7f2e8, var(--bg));
color: var(--ink);
}
.layout {
display: grid;
grid-template-columns: 20rem 1fr 26rem;
min-height: 100vh;
}
.panel {
padding: 1.25rem;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(10px);
overflow: auto;
}
.panel:last-child {
border-right: 0;
}
h1, h2, h3 {
margin: 0 0 0.75rem;
}
p, label, li, small {
color: var(--muted);
line-height: 1.5;
}
form, .stack {
display: grid;
gap: 0.75rem;
}
.section {
margin-top: 1.5rem;
}
.surface {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1rem;
box-shadow: 0 10px 30px rgba(68, 58, 36, 0.06);
}
input, textarea, select, button {
width: 100%;
font: inherit;
}
input, textarea, select {
padding: 0.78rem 0.9rem;
border-radius: 12px;
border: 1px solid var(--line);
background: #fffdf9;
color: var(--ink);
}
textarea {
min-height: 9rem;
resize: vertical;
}
button {
border: 0;
border-radius: 999px;
padding: 0.78rem 1rem;
cursor: pointer;
background: var(--accent);
color: #f5f7ef;
font-weight: 600;
transition: transform 140ms ease, background 140ms ease;
}
button:hover {
transform: translateY(-1px);
background: var(--accent-strong);
}
button.secondary {
background: rgba(44, 110, 73, 0.12);
color: var(--accent-strong);
}
button.danger {
background: rgba(139, 58, 42, 0.12);
color: var(--danger);
}
.project-list, .card-list, .result-list, .tree-list, .collection-items-list {
display: grid;
gap: 0.75rem;
}
.project-item, .card-item, .result-item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.65);
}
.project-item.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15);
}
.row {
display: flex;
gap: 0.6rem;
align-items: center;
}
.row > * {
flex: 1;
}
.meta {
font-size: 0.9rem;
color: var(--muted);
}
.status {
min-height: 1.4rem;
font-size: 0.95rem;
color: var(--accent-strong);
}
.status.error {
color: var(--danger);
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: 16px;
color: var(--muted);
}
.tree-node {
display: grid;
gap: 0.35rem;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.45rem;
}
.tree-toggle {
width: 1.6rem;
min-width: 1.6rem;
height: 1.6rem;
padding: 0;
border-radius: 10px;
background: rgba(44, 110, 73, 0.08);
color: var(--accent-strong);
}
.tree-label {
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.55rem 0.7rem;
background: rgba(255, 255, 255, 0.6);
color: var(--ink);
}
.tree-label.active {
border-color: rgba(44, 110, 73, 0.22);
background: rgba(44, 110, 73, 0.12);
}
.tree-children {
display: grid;
gap: 0.35rem;
margin-left: 1rem;
padding-left: 0.6rem;
border-left: 1px dashed var(--line);
}
.collection-item {
display: grid;
gap: 0.35rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.62);
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.checkbox-row input[type="checkbox"] {
width: auto;
margin-top: 0.3rem;
}
.action-bar {
display: grid;
gap: 0.55rem;
padding-top: 0.4rem;
border-top: 1px solid var(--line);
}
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
.modal-overlay {
position: absolute;
inset: 0;
border-radius: 0;
background: rgba(24, 28, 22, 0.42);
}
.modal-card {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: 1rem auto;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
}
.modal-header,
.modal-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.modal-action-bar {
border-top: 1px solid var(--line);
border-bottom: 0;
}
.modal-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.modal-body {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.2fr);
min-height: 24rem;
}
.modal-pane {
display: grid;
gap: 0.6rem;
padding: 1rem 1.25rem;
min-height: 0;
}
.modal-scroll {
min-height: 0;
overflow: auto;
}
.preview-popover {
position: absolute;
max-width: 28rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 248, 0.98);
box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16);
color: var(--ink);
}
.tabs {
display: flex;
gap: 0.5rem;
}
.tabs button {
background: rgba(44, 110, 73, 0.08);
color: var(--accent-strong);
}
.tabs button.active {
background: var(--accent);
color: #f5f7ef;
}
.tab-panel[hidden] {
display: none;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.panel {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.modal-card {
width: calc(100vw - 1rem);
margin: 0.5rem auto;
max-height: calc(100vh - 1rem);
}
.modal-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
}
.modal-header,
.modal-action-bar {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<main class="layout">
<section class="panel">
<h1>Zotero KB</h1>
<p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p>
<div class="section surface">
<h3>创建项目</h3>
<form id="create-project-form" class="stack">
<input id="project-id" name="project_id" placeholder="project-id例如 thesis-ch2" required />
<input id="project-name" name="name" placeholder="项目名称" required />
<div class="row">
<input id="llm-provider" name="llm_provider" value="deepseek" />
<input id="llm-model" name="llm_model" value="deepseek-chat" />
</div>
<button type="submit">创建项目</button>
</form>
<div id="project-status" class="status"></div>
</div>
<div class="section surface">
<div class="row" style="justify-content: space-between">
<h3>项目列表</h3>
<button id="refresh-projects-button" type="button" class="secondary">刷新</button>
</div>
<div id="project-list" class="project-list"></div>
</div>
<div class="section surface">
<h3>导入文献</h3>
<p class="meta">打开子界面浏览 Zotero collection 树,再批量导入到当前项目。</p>
<button id="open-import-modal-button" type="button">导入文献</button>
</div>
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<header class="modal-header">
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-header-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="close-import-modal-button" type="button" class="secondary">关闭</button>
</div>
</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane">
<small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<footer class="modal-action-bar">
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选到当前项目</button>
</footer>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
</section>
<section class="panel">
<div class="row" style="justify-content: space-between">
<div>
<h2>Cards</h2>
<p id="current-project-label">当前未选择项目。</p>
</div>
<button id="reload-cards-button" type="button" class="secondary">刷新卡片</button>
</div>
<div id="card-list" class="card-list"></div>
<div class="section surface">
<h3>卡片详情</h3>
<div id="card-detail" class="empty">点击卡片后这里会显示摘要、claims 和可引用内容。</div>
</div>
</section>
<section class="panel">
<h2>Writing</h2>
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
<div class="tabs">
<button id="recommend-tab" type="button" class="active">引用推荐</button>
<button id="plan-tab" type="button">引用方案</button>
</div>
<section id="recommend-panel" class="tab-panel section surface">
<form id="recommend-form" class="stack">
<textarea id="recommend-prompt" placeholder="输入本段写作意图或草稿"></textarea>
<button type="submit">生成候选引用</button>
</form>
<div id="recommend-status" class="status"></div>
<div id="recommend-results" class="result-list"></div>
</section>
<section id="plan-panel" class="tab-panel section surface" hidden>
<form id="plan-form" class="stack">
<textarea id="plan-prompt" placeholder="输入本段写作意图"></textarea>
<button type="submit">生成初版引用方案</button>
</form>
<div id="plan-status" class="status"></div>
<div id="plan-results" class="result-list"></div>
</section>
</section>
</main>
<script>
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
collectionTree: [],
isImportModalOpen: false,
selectedCollectionKey: null,
expandedCollectionKeys: new Set(),
selectedItemKeys: new Set(),
visibleCollectionItems: [],
};
const elements = {
projectForm: document.getElementById("create-project-form"),
projectStatus: document.getElementById("project-status"),
projectList: document.getElementById("project-list"),
collectionStatus: document.getElementById("collection-status"),
collectionTree: document.getElementById("collection-tree"),
collectionItems: document.getElementById("collection-items"),
selectedCount: document.getElementById("selected-count"),
selectDescendantsButton: document.getElementById("select-descendants-button"),
clearSelectionButton: document.getElementById("clear-selection-button"),
importSelectedItemsButton: document.getElementById("import-selected-items-button"),
currentProjectLabel: document.getElementById("current-project-label"),
cardList: document.getElementById("card-list"),
cardDetail: document.getElementById("card-detail"),
reloadCardsButton: document.getElementById("reload-cards-button"),
refreshProjectsButton: document.getElementById("refresh-projects-button"),
recommendForm: document.getElementById("recommend-form"),
recommendStatus: document.getElementById("recommend-status"),
recommendResults: document.getElementById("recommend-results"),
planForm: document.getElementById("plan-form"),
planStatus: document.getElementById("plan-status"),
planResults: document.getElementById("plan-results"),
recommendTab: document.getElementById("recommend-tab"),
planTab: document.getElementById("plan-tab"),
recommendPanel: document.getElementById("recommend-panel"),
planPanel: document.getElementById("plan-panel"),
};
function setStatus(target, message, isError = false) {
target.textContent = message || "";
target.classList.toggle("error", Boolean(isError));
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(payload.detail || response.statusText);
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json();
}
return response.text();
}
function renderProjects() {
if (!state.projects.length) {
elements.projectList.innerHTML = '<div class="empty">还没有项目。先在上方创建一个。</div>';
return;
}
elements.projectList.innerHTML = "";
for (const project of state.projects) {
const item = document.createElement("button");
item.type = "button";
item.className = "project-item" + (project.id === state.currentProjectId ? " active" : "");
item.innerHTML = `
<strong>${project.name}</strong>
<div class="meta">${project.id}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
`;
item.addEventListener("click", () => selectProject(project.id));
elements.projectList.appendChild(item);
}
}
function renderCards(cards) {
state.currentCards = cards || [];
if (!state.currentCards.length) {
elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>';
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
return;
}
elements.cardList.innerHTML = "";
for (const card of state.currentCards) {
const item = document.createElement("div");
item.className = "card-item";
item.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
<div class="row">
<button type="button" class="secondary">查看详情</button>
<button type="button" class="danger">移出项目</button>
</div>
`;
const [detailButton, removeButton] = item.querySelectorAll("button");
detailButton.addEventListener("click", () => showCardDetail(card));
removeButton.addEventListener("click", () => removeCard(card.item_key));
elements.cardList.appendChild(item);
}
}
function showCardDetail(card) {
const claims = (card.claims || []).map((claim) => `<li>${claim}</li>`).join("") || "<li>暂无 claims</li>";
const quotes = (card.quotable_spans || []).map((quote) => `<li>${quote}</li>`).join("") || "<li>暂无可引用片段</li>";
elements.cardDetail.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
<h4>Claims</h4>
<ul>${claims}</ul>
<h4>Quotable</h4>
<ul>${quotes}</ul>
`;
}
function renderRecommendationResults(results) {
if (!results.length) {
elements.recommendResults.innerHTML = '<div class="empty">没有匹配到候选引用。</div>';
return;
}
elements.recommendResults.innerHTML = results.map((item) => `
<div class="result-item">
<strong>${item.title}</strong>
<div class="meta">${item.item_key}</div>
<p>${item.why_relevant}</p>
<pre>${(item.claims || []).join("\n")}</pre>
</div>
`).join("");
}
function renderPlanResults(sections) {
if (!sections.length) {
elements.planResults.innerHTML = '<div class="empty">没有生成方案。</div>';
return;
}
elements.planResults.innerHTML = sections.map((section) => `
<div class="result-item">
<strong>${section.heading}</strong>
<p>${section.goal}</p>
<div class="meta">${(section.citations || []).map((item) => `${item.title} (${item.role})`).join(" · ")}</div>
<pre>${(section.notes || []).join("\n")}</pre>
</div>
`).join("");
}
function renderCollectionTree() {
if (!state.collectionTree.length) {
elements.collectionTree.innerHTML = '<div class="empty">没有读取到 Zotero collections。</div>';
return;
}
elements.collectionTree.innerHTML = "";
for (const node of state.collectionTree) {
elements.collectionTree.appendChild(renderCollectionNode(node));
}
}
function renderCollectionNode(node) {
const wrapper = document.createElement("div");
wrapper.className = "tree-node";
const row = document.createElement("div");
row.className = "tree-row";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tree-toggle";
toggle.textContent = node.children && node.children.length
? (state.expandedCollectionKeys.has(node.collection_key) ? "" : "+")
: "·";
toggle.disabled = !(node.children && node.children.length);
toggle.addEventListener("click", () => toggleCollectionExpanded(node.collection_key));
const label = document.createElement("button");
label.type = "button";
label.className = "tree-label" + (state.selectedCollectionKey === node.collection_key ? " active" : "");
label.innerHTML = `
<strong>${node.name}</strong>
<div class="meta">${node.descendant_item_count} 篇</div>
`;
label.addEventListener("click", () => selectCollection(node.collection_key));
row.appendChild(toggle);
row.appendChild(label);
wrapper.appendChild(row);
if (node.children && node.children.length && state.expandedCollectionKeys.has(node.collection_key)) {
const children = document.createElement("div");
children.className = "tree-children";
for (const child of node.children) {
children.appendChild(renderCollectionNode(child));
}
wrapper.appendChild(children);
}
return wrapper;
}
function renderCollectionItems() {
renderSelectedCount();
if (!state.selectedCollectionKey) {
elements.collectionItems.innerHTML = '<div class="empty">先点击一个 collection。</div>';
return;
}
if (!state.visibleCollectionItems.length) {
elements.collectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>';
return;
}
elements.collectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) {
const row = document.createElement("label");
row.className = "collection-item";
row.innerHTML = `
<div class="checkbox-row">
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.abstract || "暂无摘要"}</p>
</div>
</div>
`;
const checkbox = row.querySelector('input[type="checkbox"]');
checkbox.addEventListener("change", (event) => {
toggleItemSelection(item.item_key, event.target.checked);
});
elements.collectionItems.appendChild(row);
}
}
function renderSelectedCount() {
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
}
function toggleItemSelection(itemKey, checked) {
if (checked) {
state.selectedItemKeys.add(itemKey);
} else {
state.selectedItemKeys.delete(itemKey);
}
renderSelectedCount();
}
function toggleCollectionExpanded(collectionKey) {
if (state.expandedCollectionKeys.has(collectionKey)) {
state.expandedCollectionKeys.delete(collectionKey);
} else {
state.expandedCollectionKeys.add(collectionKey);
}
renderCollectionTree();
}
async function loadProjects() {
const projects = await api("/api/projects");
state.projects = projects;
if (!state.currentProjectId && projects.length) {
state.currentProjectId = projects[0].id;
}
renderProjects();
if (state.currentProjectId) {
await selectProject(state.currentProjectId, false);
}
}
async function selectProject(projectId, rerender = true) {
state.currentProjectId = projectId;
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
if (rerender) {
renderProjects();
}
const payload = await api(`/api/projects/${projectId}/cards`);
renderCards(payload.cards || []);
}
async function removeCard(itemKey) {
if (!state.currentProjectId) {
return;
}
await api(`/api/projects/${state.currentProjectId}/items/${itemKey}`, { method: "DELETE" });
await selectProject(state.currentProjectId);
}
async function loadCollectionTree() {
const payload = await api("/api/zotero/collections/tree");
state.collectionTree = payload.collections || [];
if (!state.selectedCollectionKey && state.collectionTree.length) {
state.selectedCollectionKey = state.collectionTree[0].collection_key;
state.expandedCollectionKeys.add(state.selectedCollectionKey);
}
renderCollectionTree();
if (state.selectedCollectionKey) {
await selectCollection(state.selectedCollectionKey, false);
}
}
async function selectCollection(collectionKey, rerenderTree = true) {
state.selectedCollectionKey = collectionKey;
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=true`);
state.visibleCollectionItems = payload.items || [];
if (rerenderTree) {
renderCollectionTree();
}
renderCollectionItems();
}
elements.projectForm.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(elements.projectForm);
try {
await api("/api/projects", {
method: "POST",
body: JSON.stringify(Object.fromEntries(formData.entries())),
});
setStatus(elements.projectStatus, "项目已创建。");
elements.projectForm.reset();
document.getElementById("llm-provider").value = "deepseek";
document.getElementById("llm-model").value = "deepseek-chat";
await loadProjects();
} catch (error) {
setStatus(elements.projectStatus, error.message, true);
}
});
async function importItemKeys(itemKeys) {
if (!state.currentProjectId) {
setStatus(elements.collectionStatus, "请先选择项目。", true);
return;
}
try {
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: itemKeys }),
});
setStatus(elements.collectionStatus, `已导入 ${payload.imported_item_keys.length} 篇文献。`);
renderCards(payload.project_view.cards || []);
state.selectedItemKeys.clear();
renderCollectionItems();
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
}
elements.reloadCardsButton.addEventListener("click", async () => {
if (!state.currentProjectId) {
return;
}
await selectProject(state.currentProjectId);
});
elements.refreshProjectsButton.addEventListener("click", loadProjects);
elements.selectDescendantsButton.addEventListener("click", () => {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
renderCollectionItems();
});
elements.clearSelectionButton.addEventListener("click", () => {
state.selectedItemKeys.clear();
renderCollectionItems();
});
elements.importSelectedItemsButton.addEventListener("click", async () => {
await importItemKeys(Array.from(state.selectedItemKeys));
});
elements.recommendForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.currentProjectId) {
setStatus(elements.recommendStatus, "请先选择项目。", true);
return;
}
const prompt = document.getElementById("recommend-prompt").value.trim();
try {
const payload = await api(`/api/projects/${state.currentProjectId}/writing/recommend-citations`, {
method: "POST",
body: JSON.stringify({ prompt }),
});
setStatus(elements.recommendStatus, `返回 ${payload.results.length} 条候选引用。`);
renderRecommendationResults(payload.results || []);
} catch (error) {
setStatus(elements.recommendStatus, error.message, true);
}
});
elements.planForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.currentProjectId) {
setStatus(elements.planStatus, "请先选择项目。", true);
return;
}
const prompt = document.getElementById("plan-prompt").value.trim();
try {
const payload = await api(`/api/projects/${state.currentProjectId}/writing/generate-plan`, {
method: "POST",
body: JSON.stringify({ prompt }),
});
setStatus(elements.planStatus, `生成 ${payload.sections.length} 个方案段落。`);
renderPlanResults(payload.sections || []);
} catch (error) {
setStatus(elements.planStatus, error.message, true);
}
});
function activateTab(name) {
const isRecommend = name === "recommend";
elements.recommendTab.classList.toggle("active", isRecommend);
elements.planTab.classList.toggle("active", !isRecommend);
elements.recommendPanel.hidden = !isRecommend;
elements.planPanel.hidden = isRecommend;
}
elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
elements.planTab.addEventListener("click", () => activateTab("plan"));
Promise.all([loadProjects(), loadCollectionTree()]).catch((error) => {
setStatus(elements.projectStatus, error.message, true);
setStatus(elements.collectionStatus, error.message, true);
});
</script>
</body>
</html>