Phase 5 — Thread tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -837,3 +837,339 @@ h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Threads --- */
|
||||
|
||||
.threads-empty-state {
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.threads-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.threads-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.thread-create-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 18px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.thread-create-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.thread-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.thread-tab {
|
||||
flex: 1;
|
||||
padding: 10px 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.thread-tab:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.thread-tab.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thread-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.thread-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thread-card.expanded {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.thread-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread-card-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.thread-card-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thread-status-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.thread-status-active {
|
||||
color: var(--info);
|
||||
border-color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
}
|
||||
|
||||
.thread-status-resolved {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.thread-status-suspended {
|
||||
color: var(--text-muted);
|
||||
border-color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
}
|
||||
|
||||
.thread-status-complicated {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.thread-card-detail {
|
||||
padding: 4px 18px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.thread-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--bg);
|
||||
}
|
||||
|
||||
.thread-field:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.thread-field-label {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.thread-field-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.thread-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.thread-edit-btn,
|
||||
.thread-delete-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.thread-edit-btn {
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.thread-edit-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.thread-delete-btn {
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.thread-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
|
||||
}
|
||||
|
||||
/* --- Modal (Threads) --- */
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
margin: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-field-label {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-input,
|
||||
.modal-select,
|
||||
.modal-textarea {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.modal-input:focus,
|
||||
.modal-select:focus,
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.modal-actions .roll-btn,
|
||||
.modal-actions .clear-btn {
|
||||
margin-top: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -306,4 +306,5 @@
|
||||
<script src="js/meaning.js" type="module"></script>
|
||||
<script src="js/une.js" type="module"></script>
|
||||
<script src="js/dice.js" type="module"></script>
|
||||
<script src="js/threads.js" type="module"></script>
|
||||
</html>
|
||||
|
||||
@@ -53,3 +53,29 @@ export function getSystems() {
|
||||
export function getTable(name) {
|
||||
return request(`/tables/${name}`);
|
||||
}
|
||||
|
||||
export async function getThreads(campaignId) {
|
||||
const { data } = await request(`/campaigns/${campaignId}/threads`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createThread(campaignId, data) {
|
||||
const result = await request(`/campaigns/${campaignId}/threads`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function updateThread(campaignId, id, data) {
|
||||
const result = await request(`/campaigns/${campaignId}/threads/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function deleteThread(campaignId, id) {
|
||||
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
+315
-1
@@ -1 +1,315 @@
|
||||
// Mythic Oracle — threads
|
||||
// Mythic Oracle — thread tracker
|
||||
|
||||
import { getActiveCampaign } from './app.js';
|
||||
import { getThreads, createThread, updateThread, deleteThread } from './api.js';
|
||||
|
||||
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the thread tracker.';
|
||||
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
|
||||
|
||||
const FIELD_DEFS = [
|
||||
{ key: 'title', label: 'Title', type: 'text' },
|
||||
{ key: 'status', label: 'Status', type: 'select' },
|
||||
{ key: 'notes', label: 'Notes', type: 'textarea' },
|
||||
{ key: 'related_npcs', label: 'Related NPCs', type: 'text' },
|
||||
{ key: 'related_location', label: 'Related Location', type: 'text' },
|
||||
{ key: 'origin', label: 'Origin', type: 'textarea' },
|
||||
{ key: 'stakes', label: 'Stakes', type: 'textarea' },
|
||||
{ key: 'last_development', label: 'Last Development', type: 'textarea' },
|
||||
{ key: 'next_beat', label: 'Next Beat', type: 'textarea' },
|
||||
{ key: 'suspected_resolution', label: 'Suspected Resolution', type: 'textarea' },
|
||||
];
|
||||
|
||||
const container = document.getElementById('view-threads');
|
||||
|
||||
let threadsCache = [];
|
||||
let activeTab = 'active';
|
||||
let expandedId = null;
|
||||
let editingThreadId = null;
|
||||
|
||||
function statusLabel(status) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function renderNoCampaign() {
|
||||
container.innerHTML = '';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'threads-empty-state';
|
||||
msg.textContent = NO_CAMPAIGN_MESSAGE;
|
||||
container.appendChild(msg);
|
||||
container.dataset.skeleton = 'false';
|
||||
}
|
||||
|
||||
function ensureSkeleton() {
|
||||
if (container.dataset.skeleton === 'true') return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="threads-header">
|
||||
<h2 class="threads-title">Threads</h2>
|
||||
<button class="thread-create-btn" id="threadCreateBtn" type="button">+ New Thread</button>
|
||||
</div>
|
||||
<div class="thread-tabs" id="threadTabs">
|
||||
<button class="thread-tab active" data-status="active" type="button">Active</button>
|
||||
<button class="thread-tab" data-status="resolved" type="button">Resolved</button>
|
||||
<button class="thread-tab" data-status="suspended" type="button">Suspended</button>
|
||||
<button class="thread-tab" data-status="complicated" type="button">Complicated</button>
|
||||
</div>
|
||||
<div class="thread-list" id="threadList"></div>
|
||||
<div class="modal-overlay" id="threadModalOverlay">
|
||||
<div class="modal">
|
||||
<h3 class="modal-title" id="threadModalTitle">New Thread</h3>
|
||||
<div class="modal-body" id="threadModalBody"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="clear-btn" id="threadCancelBtn" type="button">Cancel</button>
|
||||
<button class="roll-btn" id="threadSaveBtn" type="button">Save Thread</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.dataset.skeleton = 'true';
|
||||
bindSkeletonEvents();
|
||||
}
|
||||
|
||||
function bindSkeletonEvents() {
|
||||
container.querySelector('#threadCreateBtn').addEventListener('click', openCreateModal);
|
||||
|
||||
container.querySelectorAll('.thread-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
if (tab.dataset.status === activeTab) return;
|
||||
activeTab = tab.dataset.status;
|
||||
container.querySelectorAll('.thread-tab').forEach((t) => t.classList.toggle('active', t === tab));
|
||||
expandedId = null;
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#threadCancelBtn').addEventListener('click', closeModal);
|
||||
container.querySelector('#threadSaveBtn').addEventListener('click', saveModal);
|
||||
container.querySelector('#threadModalOverlay').addEventListener('click', (event) => {
|
||||
if (event.target.id === 'threadModalOverlay') closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const listEl = container.querySelector('#threadList');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
const filtered = threadsCache.filter((thread) => thread.status === activeTab);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'thread-empty';
|
||||
empty.textContent = `No ${activeTab} threads.`;
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach((thread) => {
|
||||
listEl.appendChild(buildThreadCard(thread));
|
||||
});
|
||||
}
|
||||
|
||||
function buildThreadCard(thread) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'thread-card';
|
||||
|
||||
const header = document.createElement('button');
|
||||
header.type = 'button';
|
||||
header.className = 'thread-card-header';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'thread-card-title';
|
||||
titleSpan.textContent = thread.title;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `thread-status-badge thread-status-${thread.status}`;
|
||||
badge.textContent = statusLabel(thread.status);
|
||||
|
||||
header.append(titleSpan, badge);
|
||||
header.addEventListener('click', () => {
|
||||
expandedId = expandedId === thread.id ? null : thread.id;
|
||||
renderList();
|
||||
});
|
||||
card.appendChild(header);
|
||||
|
||||
if (expandedId === thread.id) {
|
||||
card.classList.add('expanded');
|
||||
card.appendChild(buildThreadDetail(thread));
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildThreadDetail(thread) {
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'thread-card-detail';
|
||||
|
||||
FIELD_DEFS.forEach(({ key, label }) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'thread-field';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'thread-field-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'thread-field-value';
|
||||
valueEl.textContent = key === 'status' ? statusLabel(thread.status) : (thread[key] || '—');
|
||||
|
||||
row.append(labelEl, valueEl);
|
||||
detail.appendChild(row);
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-card-actions';
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'thread-edit-btn';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditModal(thread));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'thread-delete-btn';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', () => handleDelete(thread));
|
||||
|
||||
actions.append(editBtn, deleteBtn);
|
||||
detail.appendChild(actions);
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
function buildModalForm(values) {
|
||||
const body = container.querySelector('#threadModalBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
FIELD_DEFS.forEach(({ key, label, type }) => {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'modal-field';
|
||||
|
||||
const labelEl = document.createElement('label');
|
||||
labelEl.className = 'modal-field-label';
|
||||
labelEl.textContent = label;
|
||||
labelEl.setAttribute('for', `thread-field-${key}`);
|
||||
group.appendChild(labelEl);
|
||||
|
||||
let input;
|
||||
if (type === 'select') {
|
||||
input = document.createElement('select');
|
||||
input.className = 'modal-select';
|
||||
STATUSES.forEach((status) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = status;
|
||||
option.textContent = statusLabel(status);
|
||||
input.appendChild(option);
|
||||
});
|
||||
input.value = values.status || 'active';
|
||||
} else if (type === 'textarea') {
|
||||
input = document.createElement('textarea');
|
||||
input.className = 'modal-textarea';
|
||||
input.rows = 3;
|
||||
input.value = values[key] || '';
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'modal-input';
|
||||
input.value = values[key] || '';
|
||||
}
|
||||
input.id = `thread-field-${key}`;
|
||||
|
||||
group.appendChild(input);
|
||||
body.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
function collectFormData() {
|
||||
const data = {};
|
||||
FIELD_DEFS.forEach(({ key }) => {
|
||||
const el = container.querySelector(`#thread-field-${key}`);
|
||||
const value = el.value;
|
||||
data[key] = value.trim() === '' ? null : value;
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingThreadId = null;
|
||||
container.querySelector('#threadModalTitle').textContent = 'New Thread';
|
||||
buildModalForm({});
|
||||
showModal();
|
||||
}
|
||||
|
||||
function openEditModal(thread) {
|
||||
editingThreadId = thread.id;
|
||||
container.querySelector('#threadModalTitle').textContent = 'Edit Thread';
|
||||
buildModalForm(thread);
|
||||
showModal();
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
container.querySelector('#threadModalOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
container.querySelector('#threadModalOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveModal() {
|
||||
const campaign = getActiveCampaign();
|
||||
if (!campaign) return;
|
||||
|
||||
const data = collectFormData();
|
||||
if (!data.title) {
|
||||
alert('Title is required.');
|
||||
return;
|
||||
}
|
||||
if (!data.status) data.status = 'active';
|
||||
|
||||
try {
|
||||
if (editingThreadId === null) {
|
||||
await createThread(campaign.id, data);
|
||||
} else {
|
||||
await updateThread(campaign.id, editingThreadId, data);
|
||||
}
|
||||
closeModal();
|
||||
await loadAndRender();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(thread) {
|
||||
const confirmed = confirm(`Delete "${thread.title}"? This cannot be undone.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const campaign = getActiveCampaign();
|
||||
if (!campaign) return;
|
||||
|
||||
await deleteThread(campaign.id, thread.id);
|
||||
expandedId = null;
|
||||
await loadAndRender();
|
||||
}
|
||||
|
||||
async function loadAndRender() {
|
||||
const campaign = getActiveCampaign();
|
||||
if (!campaign) {
|
||||
renderNoCampaign();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSkeleton();
|
||||
threadsCache = await getThreads(campaign.id);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function init() {
|
||||
loadAndRender();
|
||||
|
||||
const navBtn = document.querySelector('.nav-item[data-view="threads"]');
|
||||
if (navBtn) {
|
||||
navBtn.addEventListener('click', () => loadAndRender());
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in New Issue
Block a user