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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,20 @@
|
||||
>
|
||||
<button id="next" type="submit">Next</button>
|
||||
</form>
|
||||
<div id="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden>
|
||||
<div id="recent-list" class="recent-list"></div>
|
||||
<div id="library-panel" class="library-panel" aria-label="Saved streams">
|
||||
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
|
||||
<div class="column-heading">
|
||||
<h2 id="recent-heading">Recents</h2>
|
||||
</div>
|
||||
<div id="recent-list" class="url-list"></div>
|
||||
</section>
|
||||
<section id="favorites-panel" class="url-column" aria-labelledby="favorites-heading">
|
||||
<div class="column-heading">
|
||||
<h2 id="favorites-heading">Favorites</h2>
|
||||
<button id="edit-favorites" type="button" class="small-button">Edit</button>
|
||||
</div>
|
||||
<div id="favorites-list" class="url-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
|
||||
@@ -48,6 +60,22 @@
|
||||
</div>
|
||||
<audio id="audio" preload="none"></audio>
|
||||
</section>
|
||||
|
||||
<div id="favorites-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="favorites-modal-title" hidden>
|
||||
<form id="favorites-form" class="modal-panel">
|
||||
<div class="modal-heading">
|
||||
<h2 id="favorites-modal-title">Edit Favorites</h2>
|
||||
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">×</button>
|
||||
</div>
|
||||
<div id="favorites-editor-list" class="favorites-editor-list"></div>
|
||||
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button>
|
||||
<div id="favorites-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="cancel-favorites" type="button" class="secondary-button">Cancel</button>
|
||||
<button id="save-favorites" type="submit" class="primary-button">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
|
||||
@@ -64,7 +64,7 @@ button:disabled {
|
||||
|
||||
.entry-stack {
|
||||
display: grid;
|
||||
width: min(760px, 100%);
|
||||
width: min(980px, 100%);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,12 @@ button:disabled {
|
||||
}
|
||||
|
||||
.url-form button,
|
||||
.control-button {
|
||||
.control-button,
|
||||
.small-button,
|
||||
.secondary-button,
|
||||
.primary-button,
|
||||
.icon-button,
|
||||
.remove-favorite {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--fg);
|
||||
@@ -111,28 +116,61 @@ button:disabled {
|
||||
color: #070707;
|
||||
}
|
||||
|
||||
.recent-panel {
|
||||
.library-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.url-column {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
.column-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.2rem;
|
||||
padding: 0.55rem 0.75rem 0.2rem 1rem;
|
||||
}
|
||||
|
||||
.column-heading h2,
|
||||
.modal-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.small-button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 4.2rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.url-list {
|
||||
display: grid;
|
||||
max-height: min(42vh, 24rem);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.recent-url {
|
||||
.url-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 1rem;
|
||||
color: var(--soft);
|
||||
text-align: left;
|
||||
@@ -141,13 +179,19 @@ button:disabled {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.recent-url:hover,
|
||||
.recent-url:focus {
|
||||
.url-item:hover,
|
||||
.url-item:focus {
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.empty-list {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
color: rgba(246, 241, 232, 0.42);
|
||||
}
|
||||
|
||||
.message {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
@@ -365,6 +409,107 @@ audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
display: grid;
|
||||
width: min(860px, 100%);
|
||||
max-height: min(760px, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
gap: 0.85rem;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--glass-strong);
|
||||
box-shadow: 0 28px 120px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.modal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 2.3rem;
|
||||
height: 2.3rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.favorites-editor-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.favorite-editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.favorite-editor-row input {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.72rem 0.8rem;
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.favorite-editor-row input::placeholder {
|
||||
color: rgba(246, 241, 232, 0.42);
|
||||
}
|
||||
|
||||
.favorite-editor-row input:focus {
|
||||
border-color: rgba(232, 168, 79, 0.7);
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.primary-button,
|
||||
.remove-favorite {
|
||||
min-height: 2.7rem;
|
||||
padding: 0.65rem 0.95rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: transparent;
|
||||
color: #070707;
|
||||
background: var(--fg);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
min-height: 1.2rem;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.modal-message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -386,6 +531,29 @@ audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.library-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.url-list {
|
||||
max-height: 28vh;
|
||||
}
|
||||
|
||||
.favorite-editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.remove-favorite,
|
||||
.secondary-button,
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
|
||||
Reference in New Issue
Block a user