adds favorites

This commit is contained in:
2026-05-04 20:33:14 -07:00
parent 47dae48673
commit 5e2a2e1de7
7 changed files with 599 additions and 23 deletions

View File

@@ -5,8 +5,20 @@ const elements = {
url: document.querySelector('#stream-url'),
next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
favoritesPanel: document.querySelector('#favorites-panel'),
favoritesList: document.querySelector('#favorites-list'),
editFavorites: document.querySelector('#edit-favorites'),
favoritesModal: document.querySelector('#favorites-modal'),
favoritesForm: document.querySelector('#favorites-form'),
closeFavorites: document.querySelector('#close-favorites'),
cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'),
favoritesEditorList: document.querySelector('#favorites-editor-list'),
favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
@@ -49,6 +61,8 @@ const state = {
controlsVisible: true,
hideControlsTimer: 0,
recentUrls: [],
favorites: [],
favoriteModalTrigger: null,
playbackOffset: 0,
duration: null,
seekable: false,
@@ -60,7 +74,7 @@ const state = {
lastPlaybackRestartAt: 0,
};
void loadRecentUrls();
void loadEntryLists();
elements.form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -110,6 +124,68 @@ elements.recentList.addEventListener('click', (event) => {
elements.form.requestSubmit();
});
elements.favoritesList.addEventListener('click', (event) => {
const button = event.target.closest('[data-favorite-index]');
if (!button) {
return;
}
const item = state.favorites[Number(button.dataset.favoriteIndex)];
if (!item) {
return;
}
elements.url.value = item.url;
elements.form.requestSubmit();
});
elements.editFavorites.addEventListener('click', () => {
openFavoritesModal();
});
elements.closeFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.cancelFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.addFavorite.addEventListener('click', () => {
elements.favoritesEditorList.append(createFavoriteEditorRow());
focusLastFavoriteTitle();
});
elements.favoritesEditorList.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-favorite]');
if (!button) {
return;
}
button.closest('.favorite-editor-row')?.remove();
ensureFavoriteEditorRows();
});
elements.favoritesForm.addEventListener('submit', (event) => {
event.preventDefault();
void saveFavoritesFromModal();
});
elements.favoritesModal.addEventListener('click', (event) => {
if (event.target === elements.favoritesModal) {
closeFavoritesModal();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !elements.favoritesModal.hidden) {
closeFavoritesModal();
}
});
elements.stage.addEventListener('pointerup', (event) => {
if (!state.session || event.target.closest('.controls')) {
return;
@@ -939,7 +1015,7 @@ function syncControlLabels() {
function showEntry() {
elements.playerScreen.hidden = true;
elements.entryScreen.hidden = false;
void loadRecentUrls();
void loadEntryLists();
elements.url.focus();
}
@@ -964,11 +1040,18 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.next.disabled = isBusy;
for (const button of elements.recentList.querySelectorAll('button')) {
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
}
}
async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
}
async function loadRecentUrls() {
try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
@@ -991,12 +1074,170 @@ function renderRecentUrls() {
...state.recentUrls.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-url';
button.className = 'url-item';
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
elements.recentPanel.hidden = state.recentUrls.length === 0;
if (state.recentUrls.length === 0) {
elements.recentList.replaceChildren(createEmptyListMessage('No recents'));
}
}
async function loadFavorites() {
try {
const response = await fetch('/api/favorites', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load favorites.');
}
const payload = await response.json();
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
} catch {
state.favorites = [];
renderFavorites();
}
}
function renderFavorites() {
elements.favoritesList.replaceChildren(
...state.favorites.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'url-item';
button.dataset.favoriteIndex = String(index);
button.textContent = item.title;
return button;
}),
);
if (state.favorites.length === 0) {
elements.favoritesList.replaceChildren(createEmptyListMessage('No favorites'));
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';
message.textContent = text;
return message;
}
function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites);
elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField();
}
function closeFavoritesModal() {
elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = '';
state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null;
}
function renderFavoriteEditorRows(items) {
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)),
);
}
function createFavoriteEditorRow(item = { title: '', url: '' }) {
const row = document.createElement('div');
row.className = 'favorite-editor-row';
const title = document.createElement('input');
title.type = 'text';
title.placeholder = 'Title';
title.setAttribute('aria-label', 'Favorite title');
title.autocomplete = 'off';
title.maxLength = 120;
title.value = item.title ?? '';
title.dataset.favoriteTitle = '';
const url = document.createElement('input');
url.type = 'url';
url.placeholder = 'URL';
url.setAttribute('aria-label', 'Favorite URL');
url.autocomplete = 'off';
url.value = item.url ?? '';
url.dataset.favoriteUrl = '';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-favorite';
remove.dataset.removeFavorite = '';
remove.setAttribute('aria-label', 'Remove favorite');
remove.textContent = 'Remove';
row.append(title, url, remove);
return row;
}
function ensureFavoriteEditorRows() {
if (elements.favoritesEditorList.children.length === 0) {
elements.favoritesEditorList.append(createFavoriteEditorRow());
}
}
function focusFirstFavoriteField() {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
field?.focus();
}
function focusLastFavoriteTitle() {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus();
}
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = '';
setFavoritesModalBusy(true);
try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favorites }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to save favorites.');
}
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
closeFavoritesModal();
} catch (error) {
elements.favoritesMessage.textContent = error.message;
} finally {
setFavoritesModalBusy(false);
}
}
function readFavoriteEditorRows() {
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
.map((row) => ({
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
}))
.filter((item) => item.title || item.url);
}
function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
}