add all frontend TypeScript modules
This commit is contained in:
538
src/ui.ts
Normal file
538
src/ui.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* UI controls - zoom, split ratios, topbar, recent menu, info panel,
|
||||
* notes, toast notifications, and reset/reload buttons.
|
||||
*/
|
||||
import { api } from './api';
|
||||
import type { VideoItem } from './types';
|
||||
import {
|
||||
library, currentIndex, prefs, setPrefs,
|
||||
clamp, fmtTime, fmtBytes, fmtDate, fmtBitrate,
|
||||
currentItem, cb,
|
||||
} from './store';
|
||||
|
||||
// ---- DOM refs ----
|
||||
let contentGrid: HTMLElement;
|
||||
let divider: HTMLElement;
|
||||
let dockGrid: HTMLElement;
|
||||
let dockDivider: HTMLElement;
|
||||
let zoomOutBtn: HTMLElement;
|
||||
let zoomInBtn: HTMLElement;
|
||||
let zoomResetBtn: HTMLElement;
|
||||
let onTopChk: HTMLInputElement;
|
||||
let autoplayChk: HTMLInputElement;
|
||||
let chooseBtn: HTMLElement;
|
||||
let chooseDropBtn: HTMLElement;
|
||||
let recentMenu: HTMLElement;
|
||||
let refreshBtn: HTMLElement;
|
||||
let resetProgBtn: HTMLElement;
|
||||
let notesBox: HTMLTextAreaElement;
|
||||
let notesSaved: HTMLElement;
|
||||
let nowTitle: HTMLElement;
|
||||
let nowSub: HTMLElement;
|
||||
let overallBar: HTMLElement;
|
||||
let overallPct: HTMLElement;
|
||||
let toast: HTMLElement;
|
||||
let toastMsg: HTMLElement;
|
||||
let infoGridEl: HTMLElement;
|
||||
|
||||
// Info panel elements
|
||||
let infoFolder: HTMLElement;
|
||||
let infoNext: HTMLElement;
|
||||
let infoStruct: HTMLElement;
|
||||
let infoTitle: HTMLElement;
|
||||
let infoRel: HTMLElement;
|
||||
let infoPos: HTMLElement;
|
||||
let infoFileBits: HTMLElement;
|
||||
let infoVidBits: HTMLElement;
|
||||
let infoAudBits: HTMLElement;
|
||||
let infoSubsBits: HTMLElement;
|
||||
let infoFinished: HTMLElement;
|
||||
let infoRemaining: HTMLElement;
|
||||
let infoEta: HTMLElement;
|
||||
let infoVolume: HTMLElement;
|
||||
let infoSpeed: HTMLElement;
|
||||
let infoKnown: HTMLElement;
|
||||
let infoTop: HTMLElement;
|
||||
|
||||
// ---- State ----
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let draggingDivider = false;
|
||||
let draggingDockDivider = false;
|
||||
let saveSplitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveDockTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let recentOpen = false;
|
||||
|
||||
// ---- Player ref for position info ----
|
||||
let player: HTMLVideoElement;
|
||||
|
||||
export function initUI(): void {
|
||||
contentGrid = document.getElementById('contentGrid')!;
|
||||
divider = document.getElementById('divider')!;
|
||||
dockGrid = document.getElementById('dockGrid')!;
|
||||
dockDivider = document.getElementById('dockDivider')!;
|
||||
zoomOutBtn = document.getElementById('zoomOutBtn')!;
|
||||
zoomInBtn = document.getElementById('zoomInBtn')!;
|
||||
zoomResetBtn = document.getElementById('zoomResetBtn')!;
|
||||
onTopChk = document.getElementById('onTopChk') as HTMLInputElement;
|
||||
autoplayChk = document.getElementById('autoplayChk') as HTMLInputElement;
|
||||
chooseBtn = document.getElementById('chooseBtn')!;
|
||||
chooseDropBtn = document.getElementById('chooseDropBtn')!;
|
||||
recentMenu = document.getElementById('recentMenu')!;
|
||||
refreshBtn = document.getElementById('refreshBtn')!;
|
||||
resetProgBtn = document.getElementById('resetProgBtn')!;
|
||||
notesBox = document.getElementById('notesBox') as HTMLTextAreaElement;
|
||||
notesSaved = document.getElementById('notesSaved')!;
|
||||
nowTitle = document.getElementById('nowTitle')!;
|
||||
nowSub = document.getElementById('nowSub')!;
|
||||
overallBar = document.getElementById('overallBar')!;
|
||||
overallPct = document.getElementById('overallPct')!;
|
||||
toast = document.getElementById('toast')!;
|
||||
toastMsg = document.getElementById('toastMsg')!;
|
||||
infoGridEl = document.getElementById('infoGrid')!;
|
||||
player = document.getElementById('player') as HTMLVideoElement;
|
||||
|
||||
infoFolder = document.getElementById('infoFolder')!;
|
||||
infoNext = document.getElementById('infoNext')!;
|
||||
infoStruct = document.getElementById('infoStruct')!;
|
||||
infoTitle = document.getElementById('infoTitle')!;
|
||||
infoRel = document.getElementById('infoRel')!;
|
||||
infoPos = document.getElementById('infoPos')!;
|
||||
infoFileBits = document.getElementById('infoFileBits')!;
|
||||
infoVidBits = document.getElementById('infoVidBits')!;
|
||||
infoAudBits = document.getElementById('infoAudBits')!;
|
||||
infoSubsBits = document.getElementById('infoSubsBits')!;
|
||||
infoFinished = document.getElementById('infoFinished')!;
|
||||
infoRemaining = document.getElementById('infoRemaining')!;
|
||||
infoEta = document.getElementById('infoEta')!;
|
||||
infoVolume = document.getElementById('infoVolume')!;
|
||||
infoSpeed = document.getElementById('infoSpeed')!;
|
||||
infoKnown = document.getElementById('infoKnown')!;
|
||||
infoTop = document.getElementById('infoTop')!;
|
||||
|
||||
// --- Info panel scroll fades ---
|
||||
if (infoGridEl) {
|
||||
const updateInfoFades = () => {
|
||||
const atTop = infoGridEl.scrollTop < 5;
|
||||
const atBottom = infoGridEl.scrollTop + infoGridEl.clientHeight >= infoGridEl.scrollHeight - 5;
|
||||
infoGridEl.classList.toggle('at-top', atTop);
|
||||
infoGridEl.classList.toggle('at-bottom', atBottom);
|
||||
};
|
||||
infoGridEl.addEventListener('scroll', updateInfoFades);
|
||||
setTimeout(updateInfoFades, 100);
|
||||
setTimeout(updateInfoFades, 500);
|
||||
}
|
||||
|
||||
// --- Divider drag ---
|
||||
divider.addEventListener('mousedown', (e) => {
|
||||
draggingDivider = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
dockDivider.addEventListener('mousedown', (e) => {
|
||||
draggingDockDivider = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', async () => {
|
||||
if (draggingDivider) {
|
||||
draggingDivider = false;
|
||||
document.body.style.userSelect = '';
|
||||
if (prefs && typeof prefs.split_ratio === 'number') {
|
||||
await savePrefsPatch({ split_ratio: prefs.split_ratio });
|
||||
}
|
||||
}
|
||||
if (draggingDockDivider) {
|
||||
draggingDockDivider = false;
|
||||
document.body.style.userSelect = '';
|
||||
if (prefs && typeof prefs.dock_ratio === 'number') {
|
||||
await savePrefsPatch({ dock_ratio: prefs.dock_ratio });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (draggingDivider) {
|
||||
const rect = contentGrid.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
if (!prefs) setPrefs({});
|
||||
prefs!.split_ratio = applySplit(x / rect.width);
|
||||
if (saveSplitTimer) clearTimeout(saveSplitTimer);
|
||||
saveSplitTimer = setTimeout(() => {
|
||||
if (prefs) savePrefsPatch({ split_ratio: prefs.split_ratio });
|
||||
}, 400);
|
||||
}
|
||||
if (draggingDockDivider) {
|
||||
const rect = dockGrid.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
if (!prefs) setPrefs({});
|
||||
prefs!.dock_ratio = applyDockSplit(x / rect.width);
|
||||
if (saveDockTimer) clearTimeout(saveDockTimer);
|
||||
saveDockTimer = setTimeout(() => {
|
||||
if (prefs) savePrefsPatch({ dock_ratio: prefs.dock_ratio });
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Zoom controls ---
|
||||
zoomOutBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) - 0.1);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
zoomInBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) + 0.1);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
zoomResetBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(1.0);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
|
||||
// --- On Top toggle ---
|
||||
onTopChk.addEventListener('change', async () => {
|
||||
const enabled = !!onTopChk.checked;
|
||||
try {
|
||||
await api.setAlwaysOnTop(enabled);
|
||||
prefs!.always_on_top = enabled;
|
||||
await savePrefsPatch({ always_on_top: enabled });
|
||||
notify(enabled ? 'On top enabled.' : 'On top disabled.');
|
||||
} catch (_) {
|
||||
onTopChk.checked = !!prefs?.always_on_top;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Autoplay toggle ---
|
||||
autoplayChk.addEventListener('change', async () => {
|
||||
if (!library) return;
|
||||
const enabled = !!autoplayChk.checked;
|
||||
try {
|
||||
const res = await api.setFolderAutoplay(enabled);
|
||||
if (res && res.ok) {
|
||||
(library as any).folder_autoplay = enabled;
|
||||
updateInfoPanel();
|
||||
notify(enabled ? 'Autoplay: ON' : 'Autoplay: OFF');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// --- Reset progress ---
|
||||
resetProgBtn.addEventListener('click', async () => {
|
||||
if (!library) return;
|
||||
try {
|
||||
const res = await api.resetWatchProgress();
|
||||
if (res && res.ok) {
|
||||
notify('Progress reset for this folder.');
|
||||
const info = await api.getLibrary();
|
||||
if (info && info.ok) {
|
||||
await cb.onLibraryLoaded?.(info, false);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// --- Open folder / Recent menu ---
|
||||
chooseBtn.onclick = async () => {
|
||||
closeRecentMenu();
|
||||
const info = await api.selectFolder();
|
||||
if (!info || !info.ok) return;
|
||||
await cb.onLibraryLoaded?.(info, true);
|
||||
notify('Folder loaded.');
|
||||
};
|
||||
|
||||
chooseDropBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (recentOpen) closeRecentMenu();
|
||||
else await openRecentMenu();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', () => { if (recentOpen) positionRecentMenu(); });
|
||||
window.addEventListener('scroll', () => { if (recentOpen) positionRecentMenu(); }, true);
|
||||
window.addEventListener('click', () => { if (recentOpen) closeRecentMenu(); });
|
||||
recentMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// --- Reload ---
|
||||
refreshBtn.onclick = async () => {
|
||||
const info = await api.getLibrary();
|
||||
if (!info || !info.ok) return;
|
||||
await cb.onLibraryLoaded?.(info, false);
|
||||
notify('Reloaded.');
|
||||
};
|
||||
|
||||
// --- Notes ---
|
||||
notesBox.addEventListener('input', () => {
|
||||
if (!library) return;
|
||||
const it = currentItem();
|
||||
if (!it) return;
|
||||
if (noteSaveTimer) clearTimeout(noteSaveTimer);
|
||||
if (notesSavedTimer) clearTimeout(notesSavedTimer);
|
||||
if (notesSaved) notesSaved.classList.remove('show');
|
||||
noteSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
await api.setNote(it.fid, notesBox.value || '');
|
||||
if (notesSaved) {
|
||||
notesSaved.classList.add('show');
|
||||
notesSavedTimer = setTimeout(() => { notesSaved.classList.remove('show'); }, 2000);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Exported functions ----
|
||||
|
||||
export function applyZoom(z: number): number {
|
||||
const zoom = clamp(Number(z || 1.0), 0.75, 2.0);
|
||||
document.documentElement.style.setProperty('--zoom', String(zoom));
|
||||
if (zoomResetBtn) zoomResetBtn.textContent = `${Math.round(zoom * 100)}%`;
|
||||
document.body.getBoundingClientRect(); // force reflow
|
||||
return zoom;
|
||||
}
|
||||
|
||||
export function applySplit(ratio: number): number {
|
||||
const r = clamp(Number(ratio || 0.62), 0.35, 0.80);
|
||||
contentGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function applyDockSplit(ratio: number): number {
|
||||
const r = clamp(Number(ratio || 0.62), 0.35, 0.80);
|
||||
dockGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function notify(msg: string): void {
|
||||
if (!toastMsg || !toast) return;
|
||||
toastMsg.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toast.classList.remove('show'); }, 2600);
|
||||
}
|
||||
|
||||
export function updateNowHeader(it: VideoItem | null): void {
|
||||
nowTitle.textContent = it ? (it.title || it.name) : 'No video loaded';
|
||||
nowSub.textContent = it ? it.relpath : '-';
|
||||
}
|
||||
|
||||
export function updateOverall(): void {
|
||||
if (!library) { overallBar.style.width = '0%'; overallPct.textContent = '-'; return; }
|
||||
if (library.overall_progress === null || library.overall_progress === undefined) {
|
||||
overallBar.style.width = '0%'; overallPct.textContent = '-'; return;
|
||||
}
|
||||
const p = clamp(library.overall_progress, 0, 1);
|
||||
overallBar.style.width = `${(p * 100).toFixed(1)}%`;
|
||||
overallPct.textContent = `${(p * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function updateInfoPanel(): void {
|
||||
const it = currentItem();
|
||||
infoFolder.textContent = library?.folder || '-';
|
||||
infoNext.textContent = library?.next_up ? library.next_up.title : '-';
|
||||
infoStruct.textContent = library ? (library.has_subdirs ? 'Subfolders detected' : 'Flat folder') : '-';
|
||||
infoTitle.textContent = it?.title || '-';
|
||||
infoRel.textContent = it?.relpath || '-';
|
||||
|
||||
const t = player?.currentTime || 0;
|
||||
const d = player && Number.isFinite(player.duration) ? player.duration : 0;
|
||||
infoPos.textContent = d > 0 ? `${fmtTime(t)} / ${fmtTime(d)}` : fmtTime(t);
|
||||
|
||||
if (library) {
|
||||
infoFinished.textContent = `${library.finished_count ?? 0}`;
|
||||
infoRemaining.textContent = `${library.remaining_count ?? 0}`;
|
||||
infoEta.textContent = (library.remaining_seconds_known != null) ? fmtTime(library.remaining_seconds_known) : '-';
|
||||
infoKnown.textContent = `${library.durations_known || 0}/${library.count || 0}`;
|
||||
infoTop.textContent = (library.top_folders || []).map(([n, c]: [string, number]) => `${n}:${c}`).join(' \u2022 ') || '-';
|
||||
infoVolume.textContent = `${Math.round(clamp(Number(library.folder_volume ?? 1), 0, 1) * 100)}%`;
|
||||
infoSpeed.textContent = `${Number(library.folder_rate ?? 1).toFixed(2)}x`;
|
||||
} else {
|
||||
infoFinished.textContent = '-';
|
||||
infoRemaining.textContent = '-';
|
||||
infoEta.textContent = '-';
|
||||
infoKnown.textContent = '-';
|
||||
infoTop.textContent = '-';
|
||||
infoVolume.textContent = '-';
|
||||
infoSpeed.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshCurrentVideoMeta(): Promise<void> {
|
||||
const it = currentItem();
|
||||
if (!it) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.getCurrentVideoMeta();
|
||||
if (!res || !res.ok) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
const b = res.basic || ({} as any);
|
||||
const p = res.probe || null;
|
||||
const ffFound = !!res.ffprobe_found;
|
||||
|
||||
const bits: string[] = [];
|
||||
if (b.ext) bits.push(String(b.ext).toUpperCase());
|
||||
if (b.size) bits.push(fmtBytes(b.size));
|
||||
if (b.mtime) bits.push(`modified ${fmtDate(b.mtime)}`);
|
||||
if (b.folder) bits.push(`folder ${b.folder}`);
|
||||
infoFileBits.textContent = bits.join(' \u2022 ') || '-';
|
||||
|
||||
if (p) {
|
||||
const v: string[] = [];
|
||||
if (p.v_codec) v.push(String(p.v_codec).toUpperCase());
|
||||
if (p.width && p.height) v.push(`${p.width}\u00d7${p.height}`);
|
||||
if (p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`);
|
||||
if (p.pix_fmt) v.push(p.pix_fmt);
|
||||
const vb = fmtBitrate(p.v_bitrate || 0); if (vb) v.push(vb);
|
||||
infoVidBits.textContent = v.join(' \u2022 ') || '-';
|
||||
|
||||
const a: string[] = [];
|
||||
if (p.a_codec) a.push(String(p.a_codec).toUpperCase());
|
||||
if (p.channels) a.push(`${p.channels} ch`);
|
||||
if (p.sample_rate) a.push(`${(Number(p.sample_rate) / 1000).toFixed(1)} kHz`);
|
||||
const ab = fmtBitrate(p.a_bitrate || 0); if (ab) a.push(ab);
|
||||
infoAudBits.textContent = a.join(' \u2022 ') || '-';
|
||||
|
||||
const subs = p.subtitle_tracks || [];
|
||||
if (subs.length > 0) {
|
||||
const subInfo = subs.map(s => {
|
||||
const lang = s.language?.toUpperCase() || '';
|
||||
const title = s.title || '';
|
||||
return title || lang || s.codec || 'Track';
|
||||
}).join(', ');
|
||||
infoSubsBits.textContent = `${subs.length} embedded (${subInfo})`;
|
||||
} else if (it.has_sub) {
|
||||
infoSubsBits.textContent = 'External file loaded';
|
||||
} else {
|
||||
infoSubsBits.textContent = 'None';
|
||||
}
|
||||
} else {
|
||||
infoVidBits.textContent = ffFound
|
||||
? '(ffprobe available, metadata not read for this file)'
|
||||
: '(ffprobe not found)';
|
||||
infoAudBits.textContent = '-';
|
||||
infoSubsBits.textContent = it.has_sub ? 'External file loaded' : '-';
|
||||
}
|
||||
} catch (_) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNoteForCurrent(): Promise<void> {
|
||||
const it = currentItem();
|
||||
if (!it) { notesBox.value = ''; return; }
|
||||
try {
|
||||
const res = await api.getNote(it.fid);
|
||||
notesBox.value = (res && res.ok) ? (res.note || '') : '';
|
||||
} catch (_) { notesBox.value = ''; }
|
||||
}
|
||||
|
||||
export function setOnTopChecked(v: boolean): void { onTopChk.checked = v; }
|
||||
export function setAutoplayChecked(v: boolean): void { autoplayChk.checked = v; }
|
||||
|
||||
// ---- Recent menu ----
|
||||
|
||||
function ensureDropdownPortal(): void {
|
||||
try {
|
||||
if (recentMenu && recentMenu.parentElement !== document.body) {
|
||||
document.body.appendChild(recentMenu);
|
||||
}
|
||||
recentMenu.classList.add('dropdownPortal');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function positionRecentMenu(): void {
|
||||
const r = chooseDropBtn.getBoundingClientRect();
|
||||
const zoom = clamp(Number(prefs?.ui_zoom || 1), 0.75, 2.0);
|
||||
const baseW = 460, baseH = 360;
|
||||
const effW = baseW * zoom, effH = baseH * zoom;
|
||||
const left = clamp(r.right - effW, 10, window.innerWidth - effW - 10);
|
||||
const top = clamp(r.bottom + 8, 10, window.innerHeight - effH - 10);
|
||||
recentMenu.style.left = `${left}px`;
|
||||
recentMenu.style.top = `${top}px`;
|
||||
recentMenu.style.width = `${baseW}px`;
|
||||
recentMenu.style.maxHeight = `${baseH}px`;
|
||||
}
|
||||
|
||||
export function closeRecentMenu(): void {
|
||||
recentOpen = false;
|
||||
recentMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
export async function openRecentMenu(): Promise<void> {
|
||||
ensureDropdownPortal();
|
||||
try {
|
||||
const res = await api.getRecents();
|
||||
recentMenu.innerHTML = '';
|
||||
if (!res || !res.ok || !res.items || res.items.length === 0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropEmpty';
|
||||
div.textContent = 'No recent folders yet.';
|
||||
recentMenu.appendChild(div);
|
||||
} else {
|
||||
for (const it of res.items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dropItem';
|
||||
row.dataset.tooltip = it.name;
|
||||
row.dataset.tooltipDesc = it.path;
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'dropIcon';
|
||||
icon.innerHTML = '<i class="fa-solid fa-folder"></i>';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'dropName';
|
||||
name.textContent = it.name;
|
||||
|
||||
const removeBtn = document.createElement('div');
|
||||
removeBtn.className = 'dropRemove';
|
||||
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
removeBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.removeRecent(it.path);
|
||||
row.remove();
|
||||
if (recentMenu.querySelectorAll('.dropItem').length === 0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropEmpty';
|
||||
div.textContent = 'No recent folders yet.';
|
||||
recentMenu.innerHTML = '';
|
||||
recentMenu.appendChild(div);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
row.appendChild(icon); row.appendChild(name); row.appendChild(removeBtn);
|
||||
row.onclick = async () => {
|
||||
closeRecentMenu();
|
||||
const info = await api.openFolderPath(it.path);
|
||||
if (!info || !info.ok) { notify('Folder not available.'); return; }
|
||||
await cb.onLibraryLoaded?.(info, true);
|
||||
};
|
||||
recentMenu.appendChild(row);
|
||||
}
|
||||
}
|
||||
positionRecentMenu();
|
||||
recentMenu.style.display = 'block';
|
||||
recentOpen = true;
|
||||
} catch (_) { closeRecentMenu(); }
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
|
||||
await api.setPrefs(patch);
|
||||
}
|
||||
Reference in New Issue
Block a user