adds favorites
This commit is contained in:
251
public/app.js
251
public/app.js
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user