Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions __TEST__/e2e/storage.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,53 @@ test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)'
Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2);
});

test('starring pins an entry into a Starred group; unstarring removes the labels (#440)', async ({ page }) => {
await seed(page);
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) });
await row.hover();
await row.locator('.recents-kebab').click();
await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Star');
await page.locator('#recents-menu .recents-menu-star').click();

// grouped: Starred heading, beta, Recents heading, alpha — and the panel's
// static "Recents" h2 hides while the in-list h2 headings are shown
await expect(page.locator('.recents-group-heading h2')).toHaveText(['Starred', 'Recents']);
await expect(page.locator('#recents-title')).toBeHidden();
const order = await page.evaluate(() =>
[...document.querySelectorAll('#file-picker li')].map((li) => li.textContent.trim()));
expect(order[0]).toBe('Starred');
expect(order[1]).toContain('beta');
expect(order[2]).toBe('Recents');
expect(order[3]).toContain('alpha');

// autosave-style re-save must carry the star through the meta rebuild
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click();
});
await page.waitForTimeout(300);
await page.evaluate(() => {
const span = document.querySelector('#hypertranscript span[data-m]');
span.textContent = 'STAR-EDIT ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(2600);
const starredAfterSave = await page.evaluate(() => {
const key = Object.keys(localStorage).find((k) =>
k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta');
return JSON.parse(localStorage.getItem(key)).meta.starred;
});
expect(starredAfterSave).toBe(true);

// unstar → flat list again, no labels
const starredRow = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) });
await starredRow.hover();
await starredRow.locator('.recents-kebab').click();
await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Unstar');
await page.locator('#recents-menu .recents-menu-star').click();
await expect(page.locator('.recents-group-heading')).toHaveCount(0);
await expect(page.locator('#recents-title')).toBeVisible(); // default look restored
});

test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
Expand Down
20 changes: 20 additions & 0 deletions __TEST__/unit/storage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
renameTranscriptEntry,
deleteTranscriptEntry,
duplicateTranscriptEntry,
setTranscriptStarred,
mediaKeyInUse,
mediaNameFromRef,
} = require('../../js/hyperaudio-lite-editor-storage.js');
Expand Down Expand Up @@ -174,6 +175,25 @@ test('delete refcounts shared media: the blob outlives one of two duplicates (#4
assert.equal(mediaKeyInUse('m1', null, s), true); // still referenced overall
});

test('star/unstar: one-field toggle that never touches updated; copies start unstarred (#440)', () => {
const s = fakeStorage({
'hyperaudio:doc:one': entry({ meta: { name: 'a', updated: 42 } }),
});
assert.equal(setTranscriptStarred('hyperaudio:doc:one', true, s), true);
let e = JSON.parse(s.getItem('hyperaudio:doc:one'));
assert.equal(e.meta.starred, true);
assert.equal(e.meta.updated, 42); // starring must not reorder
assert.equal(listDocEntries(s)[0].starred, true); // surfaced to the renderer

const copyKey = duplicateTranscriptEntry('hyperaudio:doc:one', s);
assert.equal(JSON.parse(s.getItem(copyKey)).meta.starred, false);

assert.equal(setTranscriptStarred('hyperaudio:doc:one', false, s), true);
e = JSON.parse(s.getItem('hyperaudio:doc:one'));
assert.equal(e.meta.starred, false);
assert.equal(setTranscriptStarred('hyperaudio:doc:missing', true, s), false);
});

test('entryName / entryMediaKey fall back sensibly for malformed entries', () => {
assert.equal(entryName('alpha.hyperaudio', null), 'alpha');
assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled');
Expand Down
22 changes: 20 additions & 2 deletions css/hyperaudio-lite-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -1122,9 +1122,9 @@ label[data-a11y-wired]:focus-visible {
border: none;
background: transparent;
margin: 0;
padding: 6px 10px;
padding: 8px 16px;
border-radius: 0.375rem;
font-size: 13px;
font-size: 14px; /* match the FILE dropdown's item size */
text-align: left;
color: oklch(var(--bc) / 0.85);
cursor: pointer;
Expand All @@ -1140,3 +1140,21 @@ label[data-a11y-wired]:focus-visible {
color: oklch(var(--er));
font-weight: 600;
}

/* Starred/Recents section headings (#440) — same weight as the panel's
static "Recents" h2, which hides while these are rendered (storage.js
decides; nothing starred = static h2 only, exactly the default look). The
picker's own 16px inset aligns them with where the static h2 sits. */
#file-picker .recents-group-heading {
padding: 12px 0 4px;
pointer-events: none;
}
#file-picker .recents-group-heading h2 {
/* daisyUI styles any direct child of a menu li as a menu item — zero that
out so the heading sits at the same 16px inset as the static h2 */
margin: 0;
padding: 0;
background: none;
font-weight: 700;
font-size: 1.05rem;
}
69 changes: 63 additions & 6 deletions js/hyperaudio-lite-editor-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,31 @@ function listDocEntries(storage) {
key,
name: entryName(key, entry),
updated: meta.updated || meta.created || 0,
starred: meta.starred === true,
});
}
rows.sort((a, b) => (b.updated - a.updated) || a.name.localeCompare(b.name));
return rows;
}

/*
* Star / unstar an entry (#440). Like rename, this deliberately does not
* touch `updated` — pinning must not reorder anything by itself.
* @return {boolean} whether the change was applied
*/
function setTranscriptStarred(fileKey, starred, storage = window.localStorage) {
const entry = readTranscriptEntry(fileKey, storage);
if (entry === null) return false;
entry.meta = Object.assign({}, entry.meta, { starred: starred === true });
try {
storage.setItem(fileKey, JSON.stringify(entry));
} catch (error) {
console.error('Error starring transcript:', error);
return false;
}
return true;
}

// One-time upgrade of legacy name-keyed entries to ID-keyed entries. Runs
// every list render but is a no-op once nothing legacy-parseable remains.
function migrateLegacyEntries(storage) {
Expand Down Expand Up @@ -469,6 +488,7 @@ function saveHyperTranscriptToLocalStorage(
mediaKey: prevMeta.mediaKey || docKey,
created: prevMeta.created || now,
updated: now,
starred: prevMeta.starred === true, // meta is rebuilt — carry the star through
};

// if media url begins with blob it means it's locally cached only for the session
Expand Down Expand Up @@ -602,6 +622,7 @@ function duplicateTranscriptEntry(fileKey, storage = window.localStorage) {
mediaKey: entryMediaKey(fileKey, entry) || undefined,
created: now,
updated: now,
starred: false, // a copy starts unstarred
});
try {
storage.setItem(newKey, JSON.stringify(entry));
Expand Down Expand Up @@ -796,7 +817,8 @@ function escapeStorageMarkup(text) {

const RECENTS_RENAME_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>';
const RECENTS_DUPLICATE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
const RECENTS_KEBAB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>';
const RECENTS_KEBAB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
const RECENTS_STAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const RECENTS_DELETE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>';

function loadLocalStorageOptions(storage = window.localStorage) {
Expand All @@ -813,15 +835,35 @@ function loadLocalStorageOptions(storage = window.localStorage) {
// (prefs on every toggle) — so a positional index resolved at click time
// could load a different entry than the one listed.
const rows = listDocEntries(storage);
rows.forEach(({ key, name }) => {
const renderRow = ({ key, name }) => {
const keyAttr = escapeStorageMarkup(key);
const nameHtml = escapeStorageMarkup(name);
filePicker.insertAdjacentHTML("beforeend",
`<li class="recents-row"><a class="file-item" data-key="${keyAttr}">${nameHtml}</a>` +
`<span class="recents-actions">` +
`<button type="button" class="recents-kebab" data-key="${keyAttr}" aria-label="Options for ${nameHtml}" aria-haspopup="menu" aria-expanded="false">${RECENTS_KEBAB_SVG}</button>` +
`</span></li>`);
});
};

// Starred entries pin above the rest (#440). With nothing starred the panel
// keeps its static "Recents" h2 and the list looks as it always has; once
// something is starred that h2 hides and the list carries its own h2-level
// "Starred" / "Recents" section headings instead (they scroll with the
// rows). Ordering within each group is unchanged (last edit).
const starredRows = rows.filter((r) => r.starred);
const recentRows = rows.filter((r) => !r.starred);
const panelTitle = document.getElementById('recents-title');
if (panelTitle !== null) {
panelTitle.style.display = starredRows.length > 0 ? 'none' : '';
}
if (starredRows.length > 0) {
filePicker.insertAdjacentHTML("beforeend", `<li class="recents-group-heading"><h2>Starred</h2></li>`);
starredRows.forEach(renderRow);
if (recentRows.length > 0) {
filePicker.insertAdjacentHTML("beforeend", `<li class="recents-group-heading"><h2>Recents</h2></li>`);
}
}
recentRows.forEach(renderRow);

setFileSelectListeners();

Expand Down Expand Up @@ -906,10 +948,14 @@ function openRecentsMenu(kebabBtn) {
recentsMenuKey = fileKey;
kebabBtn.setAttribute('aria-expanded', 'true');

const menuEntry = readTranscriptEntry(fileKey, window.localStorage);
const isStarred = !!(menuEntry && menuEntry.meta && menuEntry.meta.starred === true);

const menu = document.createElement('div');
menu.id = 'recents-menu';
menu.setAttribute('role', 'menu');
menu.innerHTML =
`<button type="button" role="menuitem" class="recents-menu-star">${RECENTS_STAR_SVG}${isStarred ? 'Unstar' : 'Star'}</button>` +
`<button type="button" role="menuitem" class="recents-menu-rename">${RECENTS_RENAME_SVG}Rename</button>` +
`<button type="button" role="menuitem" class="recents-menu-duplicate">${RECENTS_DUPLICATE_SVG}Duplicate</button>` +
`<button type="button" role="menuitem" class="recents-menu-delete">${RECENTS_DELETE_SVG}Delete</button>`;
Expand All @@ -922,6 +968,11 @@ function openRecentsMenu(kebabBtn) {
? anchor.top - size.height - 4
: anchor.bottom + 4) + 'px';

menu.querySelector('.recents-menu-star').addEventListener('click', () => {
closeRecentsMenu();
setTranscriptStarred(fileKey, !isStarred);
loadLocalStorageOptions();
});
menu.querySelector('.recents-menu-rename').addEventListener('click', () => {
closeRecentsMenu();
startRecentsRename(fileKey);
Expand Down Expand Up @@ -996,7 +1047,9 @@ function startRecentsRename(fileKey, storage = window.localStorage) {

function performRecentsDelete(fileKey) {
const wasActive = activeDocKey === fileKey;
const name = entryName(fileKey, readTranscriptEntry(fileKey, window.localStorage));
const deletedEntry = readTranscriptEntry(fileKey, window.localStorage);
const name = entryName(fileKey, deletedEntry);
const wasStarred = !!(deletedEntry && deletedEntry.meta && deletedEntry.meta.starred === true);
deleteTranscriptEntry(fileKey);
loadLocalStorageOptions();

Expand All @@ -1006,7 +1059,7 @@ function performRecentsDelete(fileKey) {
showStorageNotice('Removed from Recents. The transcript is still on screen but no longer being saved.', {
tone: 'info',
sticky: true,
action: { label: 'Restore', handler: () => restoreDeletedTranscript(name) },
action: { label: 'Restore', handler: () => restoreDeletedTranscript(name, wasStarred) },
});
}
}
Expand All @@ -1015,11 +1068,14 @@ function performRecentsDelete(fileKey) {
// fresh entry under its old name. Its media blob was deleted with the entry,
// so put the media back too — a data: src (loaded from IndexedDB) is written
// directly; a blob: src re-saves through the normal path (stamp reset).
function restoreDeletedTranscript(name) {
function restoreDeletedTranscript(name, wasStarred) {
activeDocKey = null;
savedMediaStamp = null;
saveHyperTranscriptToLocalStorage(name);
if (activeDocKey !== null) {
if (wasStarred === true) {
setTranscriptStarred(activeDocKey, true); // the star survives the round trip
}
const player = document.querySelector('#hyperplayer');
if (player !== null && player.src.indexOf('data:') === 0) {
const entry = readTranscriptEntry(activeDocKey, window.localStorage);
Expand Down Expand Up @@ -1096,6 +1152,7 @@ if (typeof module !== 'undefined' && module.exports) {
renameTranscriptEntry,
deleteTranscriptEntry,
duplicateTranscriptEntry,
setTranscriptStarred,
mediaKeyInUse,
readTranscriptEntry,
escapeStorageMarkup,
Expand Down
Loading