Files
bookhoard/web/src/device-management.ts
T
John O'Keefe d15f115505 fix(devices): pairing input moves into the Add Device modal
The pairing field sat at the bottom of the Devices page, below the
device grid — unreachable on any account with more than a screenful of
devices (the user's dev account has ~480 accumulated test devices;
scrolling 224k px to reach the field is a non-starter). The Add Device
button at the top of the page is where pairing intent already lives,
so the code input and Find device button now sit inside the Connect a
KOReader Device modal as instruction step 6, with the confirm card
swapping into the same modal (container keeps the pairing-lookup ids;
the htmx error-toast listener scopes to #add-device-modal now).

Verified live: modal renders the full instruction list + input, a
wrong code swaps the templ error partial inside the modal, and claim
still HX-Refreshes on success (modal state resets with the reload,
paired device appears in the grid).

ui-controls glossary where-fields and the COVERAGE row updated with
the control's new home.
2026-11-02 16:56:32 -05:00

502 lines
18 KiB
TypeScript

import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { authHeaders, getToken } from "./storage";
function getDeviceIcon(typeName: string): string {
const deviceIcons: Record<string, string> = {
koreader: "📖",
kobo: "📚",
web: "🌐",
mobile: "📱",
};
return deviceIcons[typeName] || "📱";
}
function showAddDeviceModal(): void {
const modal = document.getElementById("add-device-modal");
if (modal) modal.classList.remove("hidden");
}
function hideAddDeviceModal(): void {
const modal = document.getElementById("add-device-modal");
if (modal) modal.classList.add("hidden");
}
function showDeviceSettings(deviceId: string): void {
const modal = document.getElementById("device-settings-modal");
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement;
if (!modal || !deviceIdInput) return;
deviceIdInput.value = deviceId;
// cookie auth carries the call; a null bearer must not strand it
let deviceType: any;
fetch(`/api/devices/${deviceId}`, {
headers: authHeaders(),
})
.then((res) => res.json())
.then((device) => {
if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
if (deviceNameInput) deviceNameInput.value = device.device_name || "";
if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false;
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
if (syncFrequencyInput)
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
deviceType = device.device_type;
return fetch("/api/collections", {
headers: authHeaders(),
});
})
.then((res) => res.json())
.then((result) => {
if (result.collections && result.collections.length > 0) {
const firstCollection = result.collections[0];
const viewSettings = firstCollection.view_settings || {};
const deviceSettings = viewSettings[deviceType] || {};
const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement;
const itemsPerPageInput = document.getElementById(
"settings-items-per-page",
) as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById(
"settings-show-progress",
) as HTMLInputElement;
if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid";
if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name";
if (itemsPerPageInput)
itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false;
if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false;
}
})
.catch((error) => {
console.error("Failed to load view settings:", error);
});
if (modal) modal.classList.remove("hidden");
}
function showShelfMappings(deviceId: string): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const modal = document.getElementById("shelf-mappings-modal");
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
loadShelfMappings(deviceId);
if (modal) modal.classList.remove("hidden");
}
function hideShelfMappingsModal(): void {
const modal = document.getElementById("shelf-mappings-modal");
if (modal) modal.classList.add("hidden");
}
async function loadShelfMappings(deviceId: string): Promise<void> {
const container = document.getElementById("shelf-mappings-container");
const token = getToken();
if (!container || !token) return;
try {
const response = await fetch(`/api/devices/${deviceId}/collections`, {
headers: authHeaders(),
});
const result = await response.json();
if (result.mappings && result.mappings.length > 0) {
container.innerHTML = result.mappings
.map(
(mapping: any) => `
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex justify-between items-center">
<div>
<h4 class="font-semibold" style="color: var(--text-primary)">${mapping.collection_name}</h4>
<p class="text-sm" style="color: var(--text-secondary)">
→ Device Shelf: <strong>${mapping.device_shelf_name}</strong>
</p>
<p class="text-xs" style="color: var(--text-secondary)">
Sync: ${mapping.sync_direction}
</p>
</div>
<div class="flex space-x-2">
<button data-action="edit-mapping" data-mapping-id="${mapping.id}" data-collection-id="${mapping.collection_id}" data-shelf-name="${mapping.device_shelf_name}" data-sync-direction="${mapping.sync_direction}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-secondary);">
✏️
</button>
<button data-action="delete-mapping" data-mapping-id="${mapping.id}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-secondary);">
🗑️
</button>
</div>
</div>
</div>
`,
)
.join("");
} else {
container.innerHTML =
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
}
} catch {
container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>';
}
}
function showAddMappingModal(): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal");
if (mappingsDeviceIdInput) {
const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
}
if (mappingIdInput) mappingIdInput.value = "";
if (mappingCollectionInput) mappingCollectionInput.value = "";
if (mappingShelfNameInput) mappingShelfNameInput.value = "";
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional";
loadCollections();
if (modal) modal.classList.remove("hidden");
}
function hideAddMappingModal(): void {
const modal = document.getElementById("add-mapping-modal");
const form = document.getElementById("mapping-form");
if (modal) modal.classList.add("hidden");
if (form && form instanceof HTMLFormElement) form.reset();
}
async function loadCollections(): Promise<void> {
const select = document.getElementById("mapping-collection") as HTMLSelectElement;
const token = getToken();
if (!select || !token) return;
try {
const response = await fetch("/api/collections", {
headers: authHeaders(),
});
const result = await response.json();
if (result.collections) {
select.innerHTML =
'<option value="">Select collection...</option>' +
result.collections
.map((col: any) => `<option value="${col.id}">${col.name}</option>`)
.join("");
}
} catch (error) {
console.error("Failed to load collections", error);
}
}
function editMapping(
mappingId: string,
collectionId: string,
shelfName: string,
syncDirection: string,
): void {
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal");
if (mappingIdInput) mappingIdInput.value = mappingId;
if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection;
if (modal) modal.classList.remove("hidden");
}
async function handleSaveMapping(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
if (
!token ||
!mappingsDeviceIdInput ||
!mappingCollectionInput ||
!mappingShelfNameInput ||
!mappingSyncDirectionInput
)
return;
const deviceId = mappingsDeviceIdInput.value;
const mappingId = mappingIdInput.value;
const isUpdate = mappingId !== "";
const data = {
collection_id: mappingCollectionInput.value,
device_shelf_name: mappingShelfNameInput.value,
sync_direction: mappingSyncDirectionInput.value,
};
const url = isUpdate
? `/api/devices/${deviceId}/collections/${mappingId}`
: `/api/devices/${deviceId}/collections`;
const method = isUpdate ? "PUT" : "POST";
try {
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
showToast(isUpdate ? "Mapping updated" : "Mapping created", "success");
hideAddMappingModal();
loadShelfMappings(deviceId);
} else {
showToast("Failed to save mapping", "error");
}
} catch (error) {
console.error("Failed to save mapping", error);
showToast("Failed to save mapping", "error");
}
}
async function deleteMapping(mappingId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this mapping?")) return;
const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
if (!token || !mappingsDeviceIdInput) return;
const deviceId = mappingsDeviceIdInput.value;
try {
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
method: "DELETE",
headers: authHeaders(),
});
if (response.ok) {
showToast("Mapping deleted", "success");
loadShelfMappings(deviceId);
} else {
showToast("Failed to delete mapping", "error");
}
} catch (error) {
console.error("Failed to delete mapping", error);
showToast("Failed to delete mapping", "error");
}
}
function hideDeviceSettingsModal(): void {
const modal = document.getElementById("device-settings-modal");
if (modal) modal.classList.add("hidden");
}
async function handleSaveDeviceSettings(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const settingsDeviceTypeInput = document.getElementById(
"settings-device-type",
) as HTMLInputElement;
const settingsDeviceNameInput = document.getElementById(
"settings-device-name",
) as HTMLInputElement;
const settingsSyncEnabledInput = document.getElementById(
"settings-sync-enabled",
) as HTMLInputElement;
const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const settingsSyncFrequencyInput = document.getElementById(
"settings-sync-frequency",
) as HTMLInputElement;
if (!token || !settingsDeviceIdInput || !settingsDeviceNameInput) return;
const deviceId = settingsDeviceIdInput.value;
const deviceType = settingsDeviceTypeInput?.value || "";
const deviceData = {
device_name: settingsDeviceNameInput.value,
sync_enabled: settingsSyncEnabledInput?.checked || false,
auto_sync: settingsAutoSyncInput?.checked || false,
sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10),
};
const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement;
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
const viewSettings = {
view_mode: viewModeInput?.value || "grid",
sort_order: sortOrderInput?.value || "name",
items_per_page: parseInt(itemsPerPageInput?.value || "24", 10),
show_covers: showCoversInput?.checked !== false,
show_progress: showProgressInput?.checked || false,
};
try {
await fetch(`/api/devices/${deviceId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(deviceData),
});
const collectionsRes = await fetch("/api/collections", {
method: "GET",
headers: authHeaders(),
});
const collections = await collectionsRes.json();
const updatePromises = collections.collections.map((collection: any) => {
const currentSettings = collection.view_settings || {};
currentSettings[deviceType] = viewSettings;
return fetch(`/api/collections/${collection.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...collection,
view_settings: currentSettings,
}),
});
});
await Promise.all(updatePromises);
showToast("Device settings saved", "success");
hideDeviceSettingsModal();
window.location.reload();
} catch (error) {
console.error("Failed to save settings", error);
showToast("Failed to save settings", "error");
}
}
async function copyToClipboard(text: string, description: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
showToast(`${description} copied!`, "success");
} catch (error) {
console.error("Failed to copy to clipboard", error);
showToast("Failed to copy to clipboard", "error");
}
}
async function handleRevokeDevice(): Promise<void> {
const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
if (!token || !settingsDeviceIdInput) return;
if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync."))
return;
const deviceId = settingsDeviceIdInput.value;
try {
const response = await fetch(`/api/devices/${deviceId}`, {
method: "DELETE",
headers: authHeaders(),
});
if (response.ok) {
showToast("Device revoked successfully", "success");
hideDeviceSettingsModal();
window.location.reload();
} else {
showToast("Failed to revoke device", "error");
}
} catch (error) {
console.error("Failed to revoke device", error);
showToast("Failed to revoke device", "error");
}
}
function setupEventDelegation(): void {
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const button = target.closest("button") as HTMLButtonElement;
if (!button) return;
const action = button.dataset.action;
if (action === "edit-mapping") {
editMapping(
button.dataset.mappingId || "",
button.dataset.collectionId || "",
button.dataset.shelfName || "",
button.dataset.syncDirection || "",
);
} else if (action === "delete-mapping") {
deleteMapping(button.dataset.mappingId || "");
}
});
// Pairing (Add Device modal): htmx only swaps 2xx bodies, so claim/lookup
// failures (wrong code, rate limit, expired) would otherwise fail
// silently. Content-level answers come from the templ partial; this
// toast covers the non-swap error path.
document.body.addEventListener("htmx:responseError", (e) => {
const detail = (e as CustomEvent<{ requestConfig?: { elt?: Element } }>).detail;
if (detail?.requestConfig?.elt?.closest("#add-device-modal")) {
showToast("Pairing failed — check the code and try again.", "error");
}
});
}
Alpine.data("devices", () => ({
copyToClipboard,
copyFromId(id: string, label: string) {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) this.copyToClipboard(el.value, label);
},
deleteMapping,
editMapping,
getDeviceIcon,
handleRevokeDevice,
handleSaveDeviceSettings,
handleSaveMapping,
hideAddDeviceModal,
hideAddMappingModal,
hideDeviceSettingsModal,
hideShelfMappingsModal,
loadCollections,
loadShelfMappings,
setupEventDelegation,
showAddDeviceModal,
showAddMappingModal,
showDeviceSettings,
showShelfMappings,
}));