Skip to content

Commit f66e3cb

Browse files
committed
Update theme and flow
1 parent c7a489c commit f66e3cb

7 files changed

Lines changed: 218 additions & 11 deletions

File tree

app.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import {
1313
renderFilterSidebar,
1414
syncFilterControlStates,
1515
renderResults,
16+
renderListPanel,
1617
buildPills,
1718
closeAddToListModal,
1819
} from './scripts/render.js';
20+
import { getLists, importLists, clearAllLists } from './scripts/lists.js';
1921
import {
2022
debouncedSearch,
2123
onSortChange,
@@ -66,6 +68,79 @@ async function init() {
6668
// Bind: close list panel button
6769
document.getElementById('close-list-panel').addEventListener('click', closeListPanel);
6870

71+
// Bind: export all lists
72+
document.getElementById('export-lists-btn').addEventListener('click', async () => {
73+
const data = getLists();
74+
const json = JSON.stringify(data, null, 2);
75+
const btn = document.getElementById('export-lists-btn');
76+
const prev = btn.textContent;
77+
78+
try {
79+
if ('showSaveFilePicker' in window) {
80+
const handle = await window.showSaveFilePicker({
81+
suggestedName: 'torchfinder-lists.json',
82+
types: [{ description: 'JSON file', accept: { 'application/json': ['.json'] } }],
83+
});
84+
const writable = await handle.createWritable();
85+
await writable.write(json);
86+
await writable.close();
87+
} else {
88+
// Fallback for Firefox: silent download to default downloads folder
89+
const a = document.createElement('a');
90+
a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(json);
91+
a.download = 'torchfinder-lists.json';
92+
document.body.appendChild(a);
93+
a.click();
94+
document.body.removeChild(a);
95+
btn.textContent = 'Saved to default download folder!';
96+
setTimeout(() => { btn.textContent = prev; }, 2500);
97+
return;
98+
}
99+
btn.textContent = 'Exported!';
100+
setTimeout(() => { btn.textContent = prev; }, 2000);
101+
} catch (e) {
102+
if (e.name !== 'AbortError') {
103+
alert('Export failed.');
104+
}
105+
}
106+
});
107+
108+
// Bind: import lists
109+
document.getElementById('import-lists-btn').addEventListener('click', () => {
110+
const input = document.createElement('input');
111+
input.type = 'file';
112+
input.accept = 'application/json,.json';
113+
input.addEventListener('change', () => {
114+
const file = input.files[0];
115+
if (!file) return;
116+
const reader = new FileReader();
117+
reader.onload = (e) => {
118+
try {
119+
const data = JSON.parse(e.target.result);
120+
if (!Array.isArray(data)) throw new Error('not an array');
121+
const count = importLists(data);
122+
renderListPanel();
123+
const btn = document.getElementById('import-lists-btn');
124+
const prev = btn.textContent;
125+
btn.textContent = `Imported ${count}`;
126+
setTimeout(() => { btn.textContent = prev; }, 2000);
127+
} catch {
128+
alert('Import failed: the file format is not valid.');
129+
}
130+
};
131+
reader.readAsText(file);
132+
});
133+
input.click();
134+
});
135+
136+
// Bind: delete all lists
137+
document.getElementById('delete-all-lists-btn').addEventListener('click', () => {
138+
if (confirm('Delete all saved lists? This cannot be undone (without an exported backup).')) {
139+
clearAllLists();
140+
renderListPanel();
141+
}
142+
});
143+
69144
// Bind: list overlay click closes list panel
70145
document.getElementById('list-overlay').addEventListener('click', closeListPanel);
71146

@@ -108,6 +183,7 @@ async function init() {
108183
state.listMode = false;
109184
state.listId = null;
110185
state.listName = '';
186+
state.listDescription = '';
111187
state.listEntries = [];
112188
state.listSynced = false;
113189
renderResults();

index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,15 @@ <h1><a class="h1-prefix" href="/">Lodes & Lanterns</a><a class="h1-title" href="
227227
<button id="close-list-panel" class="outline secondary" aria-label="Close lists panel">&#x2715;</button>
228228
</div>
229229
<div id="list-panel-content"></div>
230+
<div id="list-panel-footer">
231+
<p class="list-panel-local-note">Lists are saved in this browser only. Export to back them up.</p>
232+
<div class="list-panel-footer-btns">
233+
<button type="button" id="export-lists-btn" class="outline secondary">Export all</button>
234+
<button type="button" id="import-lists-btn" class="outline secondary">Import</button>
235+
</div>
236+
<hr />
237+
<button type="button" id="delete-all-lists-btn" class="btn-delete outline secondary">Delete all lists</button>
238+
</div>
230239
</aside>
231240

232241
<!-- Overlay for list panel -->

scripts/lists.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ export function generateListId() {
8282
return Math.random().toString(36).slice(2, 10);
8383
}
8484

85+
export function clearAllLists() {
86+
setLists([]);
87+
}
88+
89+
// ---- Export / Import --------------------------------------------------------
90+
91+
// Merges an incoming list array into localStorage.
92+
// For ID collisions, keeps whichever has the newer updatedAt timestamp.
93+
// Returns the number of lists added or updated.
94+
export function importLists(incoming) {
95+
if (!Array.isArray(incoming)) return 0;
96+
const existing = getLists();
97+
const map = new Map(existing.map((l) => [l.id, l]));
98+
let count = 0;
99+
for (const l of incoming) {
100+
if (!l.id || !Array.isArray(l.entries)) continue;
101+
const current = map.get(l.id);
102+
if (!current || (l.updatedAt || '') > (current.updatedAt || '')) {
103+
map.set(l.id, { ...l });
104+
count++;
105+
}
106+
}
107+
setLists([...map.values()]);
108+
return count;
109+
}
110+
85111
// ---- Saved state ------------------------------------------------------------
86112

87113
// Compares the current in-memory list state against localStorage.

scripts/render.js

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export function renderResults() {
219219
state.listMode = false;
220220
state.listId = null;
221221
state.listName = '';
222+
state.listDescription = '';
222223
state.listEntries = [];
223224
state.listSynced = false;
224225
renderResults();
@@ -288,6 +289,17 @@ function attachCardListeners(container) {
288289
openAddToListModal(btn.dataset.id);
289290
});
290291
});
292+
card.querySelectorAll('.copy-entry-link-btn').forEach((btn) => {
293+
btn.addEventListener('click', (e) => {
294+
e.stopPropagation();
295+
const url = `${window.location.origin}${window.location.pathname}?id=${encodeURIComponent(btn.dataset.id)}`;
296+
navigator.clipboard.writeText(url).then(() => {
297+
const prev = btn.textContent;
298+
btn.textContent = 'Copied!';
299+
setTimeout(() => { btn.textContent = prev; }, 1500);
300+
}).catch(() => {});
301+
});
302+
});
291303
});
292304
}
293305

@@ -318,7 +330,7 @@ export function renderCardHtml(entry, expanded) {
318330
<article class="result-card${expanded ? ' expanded' : ''}" data-id="${escapeHtml(entry.id)}" aria-expanded="${expanded}">
319331
<div class="card-header" role="button" tabindex="0" aria-label="${escapeHtml(entry.title)}, ${expanded ? 'collapse' : 'expand'}">
320332
<div class="card-header-main">
321-
<h3 class="card-title">${state.directId === entry.id ? `<span class="card-title-link">${escapeHtml(entry.title)}</span>` : `<a class="card-title-link" href="?id=${encodeURIComponent(entry.id)}">${escapeHtml(entry.title)}</a>`}${entry.original_publication_date ? `<span class="card-title-date"> (${escapeHtml(formatDateShort(entry.original_publication_date))})</span>` : ''}<button type="button" class="add-to-list-btn outline secondary" data-id="${escapeHtml(entry.id)}" aria-label="Add ${escapeHtml(entry.title)} to a list">+ List</button></h3>
333+
<h3 class="card-title">${state.directId === entry.id ? `<span class="card-title-link">${escapeHtml(entry.title)}</span>` : `<a class="card-title-link" href="?id=${encodeURIComponent(entry.id)}">${escapeHtml(entry.title)}</a>`}${entry.original_publication_date ? `<span class="card-title-date"> (${escapeHtml(formatDateShort(entry.original_publication_date))})</span>` : ''}<button type="button" class="add-to-list-btn outline secondary" data-id="${escapeHtml(entry.id)}" aria-label="Add ${escapeHtml(entry.title)} to a list">+ List</button><button type="button" class="copy-entry-link-btn outline secondary" data-id="${escapeHtml(entry.id)}" aria-label="Copy link to ${escapeHtml(entry.title)}">Copy link</button></h3>
322334
${authorStr ? `<div class="card-byline">${escapeHtml(authorStr)}</div>` : ''}
323335
${entry.description ? `<div class="card-description-snippet">${escapeHtml(entry.description)}</div>` : ''}
324336
</div>
@@ -621,7 +633,28 @@ function renderListView() {
621633
.join('');
622634
}
623635

624-
list.innerHTML = headerHtml + rowsHtml;
636+
const descriptionHtml = `<textarea
637+
class="list-view-description"
638+
id="list-view-description"
639+
placeholder="Add a description…"
640+
rows="1"
641+
aria-label="List description"
642+
>${escapeHtml(state.listDescription || '')}</textarea>`;
643+
644+
list.innerHTML = descriptionHtml + headerHtml + rowsHtml;
645+
646+
const descEl = document.getElementById('list-view-description');
647+
function autoResize() {
648+
descEl.style.height = 'auto';
649+
descEl.style.height = descEl.scrollHeight + 'px';
650+
}
651+
autoResize();
652+
descEl.addEventListener('input', autoResize);
653+
descEl.addEventListener('blur', () => {
654+
state.listDescription = descEl.value.trim();
655+
autoSave();
656+
updateUrl();
657+
});
625658

626659
document.getElementById('list-save-btn')?.addEventListener('click', onSaveList);
627660

@@ -848,7 +881,7 @@ export function closeAddToListModal() {
848881
function onSaveList() {
849882
if (!state.listId) state.listId = generateListId();
850883
state.listSynced = true;
851-
saveList({ id: state.listId, name: state.listName || 'Untitled list', entries: state.listEntries });
884+
saveList({ id: state.listId, name: state.listName || 'Untitled list', description: state.listDescription, entries: state.listEntries });
852885
updateUrl();
853886
renderResults();
854887
}
@@ -868,7 +901,7 @@ function onCopyListUrl() {
868901

869902
function autoSave() {
870903
if (state.listId && state.listSynced) {
871-
saveList({ id: state.listId, name: state.listName || 'Untitled list', entries: state.listEntries });
904+
saveList({ id: state.listId, name: state.listName || 'Untitled list', description: state.listDescription, entries: state.listEntries });
872905
}
873906
}
874907

@@ -907,7 +940,7 @@ function onStartRenameList() {
907940
const newName = input.value.trim() || 'Untitled list';
908941
state.listName = newName;
909942
if (state.listId && state.listSynced) {
910-
saveList({ id: state.listId, name: newName, entries: state.listEntries });
943+
saveList({ id: state.listId, name: newName, description: state.listDescription, entries: state.listEntries });
911944
}
912945
updateUrl();
913946
renderResults();
@@ -930,6 +963,7 @@ function onOpenList(id) {
930963
state.listMode = true;
931964
state.listId = id;
932965
state.listName = l.name || 'Untitled list';
966+
state.listDescription = l.description || '';
933967
state.listEntries = [...l.entries];
934968
state.listSynced = true;
935969
touchList(id);

scripts/state.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const state = {
3333
listMode: false,
3434
listId: null,
3535
listName: '',
36+
listDescription: '',
3637
listEntries: [],
3738
listSynced: false,
3839
};

scripts/url.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function parseUrlParams() {
1111
state.listMode = true;
1212
state.listEntries = decodeListPayload(listPayload);
1313
state.listName = params.get('list-name') || 'Untitled list';
14+
state.listDescription = params.get('list-description') || '';
1415
state.listId = params.get('list-id') || null;
1516
// Also restore direct-ID context if present (entry viewed from within a list).
1617
state.directId = directId;
@@ -19,6 +20,7 @@ export function parseUrlParams() {
1920
state.listMode = false;
2021
state.listEntries = [];
2122
state.listName = '';
23+
state.listDescription = '';
2224
state.listId = null;
2325

2426
state.directId = directId;
@@ -66,6 +68,7 @@ export function buildUrlParams() {
6668
if (state.listMode) {
6769
params.set('list', encodeListPayload(state.listEntries));
6870
if (state.listName) params.set('list-name', state.listName);
71+
if (state.listDescription) params.set('list-description', state.listDescription);
6972
if (state.listId) params.set('list-id', state.listId);
7073
}
7174
return params;
@@ -75,6 +78,7 @@ export function buildUrlParams() {
7578
if (state.listMode) {
7679
params.set('list', encodeListPayload(state.listEntries));
7780
if (state.listName) params.set('list-name', state.listName);
81+
if (state.listDescription) params.set('list-description', state.listDescription);
7882
if (state.listId) params.set('list-id', state.listId);
7983
return params;
8084
}

style.css

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -888,8 +888,6 @@ header {
888888
z-index: var(--tf-sidebar-z);
889889
transform: translateX(100%);
890890
transition: transform 0.25s ease;
891-
overflow-y: auto;
892-
padding: 1rem;
893891
display: flex;
894892
flex-direction: column;
895893
}
@@ -903,8 +901,7 @@ header {
903901
align-items: center;
904902
justify-content: space-between;
905903
gap: 0.5rem;
906-
margin-bottom: 1rem;
907-
padding-bottom: 0.75rem;
904+
padding: 1rem 1rem 0.75rem;
908905
border-bottom: 1px solid var(--pico-muted-border-color);
909906
flex-shrink: 0;
910907
}
@@ -922,6 +919,39 @@ header {
922919

923920
#list-panel-content {
924921
flex: 1;
922+
overflow-y: auto;
923+
padding: 1rem;
924+
}
925+
926+
#list-panel-footer {
927+
flex-shrink: 0;
928+
padding: 0.75rem 1rem;
929+
border-top: 1px solid var(--pico-muted-border-color);
930+
}
931+
932+
.list-panel-local-note {
933+
font-size: 0.75rem;
934+
color: var(--pico-muted-color);
935+
margin: 0 0 0.5rem;
936+
}
937+
938+
.list-panel-footer-btns {
939+
display: flex;
940+
gap: 0.5rem;
941+
}
942+
943+
.list-panel-footer-btns button {
944+
font-size: 0.8rem;
945+
padding: 0.25rem 0.6rem;
946+
margin: 0;
947+
}
948+
949+
#delete-all-lists-btn {
950+
display: block;
951+
width: 100%;
952+
margin-top: 0;
953+
font-size: 0.8rem;
954+
padding: 0.25rem 0.6rem;
925955
}
926956

927957
/* ---- List Overlay --------------------------------------------------------- */
@@ -1022,6 +1052,32 @@ header {
10221052
margin: 0;
10231053
}
10241054

1055+
.list-view-description {
1056+
display: block;
1057+
width: 100%;
1058+
background: var(--pico-form-element-background-color);
1059+
border: none;
1060+
border-radius: var(--pico-border-radius);
1061+
color: var(--pico-color);
1062+
font-family: inherit;
1063+
font-size: 0.9rem;
1064+
line-height: 1.5;
1065+
padding: 0.5rem 0.65rem;
1066+
margin-bottom: 0.75rem;
1067+
resize: none;
1068+
overflow: hidden;
1069+
}
1070+
1071+
.list-view-description:focus {
1072+
outline: 2px solid var(--tf-accent);
1073+
outline-offset: 0;
1074+
}
1075+
1076+
.list-view-description::placeholder {
1077+
color: var(--pico-muted-color);
1078+
font-style: italic;
1079+
}
1080+
10251081
.list-view-actions {
10261082
display: flex;
10271083
gap: 0.5rem;
@@ -1188,8 +1244,9 @@ header {
11881244
border-color: var(--pico-del-color, #c0392b);
11891245
}
11901246

1191-
/* ---- Add to List card button ---------------------------------------------- */
1192-
.add-to-list-btn {
1247+
/* ---- Card inline buttons (+ List, Copy link) ------------------------------ */
1248+
.add-to-list-btn,
1249+
.copy-entry-link-btn {
11931250
font-size: 0.7rem;
11941251
padding: 0.15rem 0.45rem;
11951252
margin: 0 0 0 0.4rem;

0 commit comments

Comments
 (0)