device: add copyToClipboard and fix regenerate token HTMX button

Phase 3: Complete device page functionality fixes

- Add copyToClipboard function to device-management.ts:
  - Uses navigator.clipboard.writeText() for copying
  - Shows success/error toast notifications
  - Already exported in Alpine.store, now properly defined
- Fix typo in devices.templ: change 'regerate-token' to 'regenerate-token'
- Add HTMX support to RegenerateDeviceToken handler:
  - Returns HTML with reload script for HTMX requests
  - Preserves JSON response for API calls

The regenerate token button now works via HTMX (hx-put) instead of
Alpine.js, matching the pattern used elsewhere in the app.
This commit is contained in:
2026-03-11 16:42:34 -04:00
parent 5a61d9e321
commit d6b702e35a
3 changed files with 211 additions and 69 deletions
+5
View File
@@ -554,6 +554,11 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL) syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL)
} }
// Handle HTMX requests - return HTML with page reload script
if c.Request().Header.Get("HX-Request") == "true" {
html := fmt.Sprintf(`<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();`)
return c.HTML(http.StatusOK, html)
}
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Token regenerated successfully", "message": "Token regenerated successfully",
"auth_token": newToken, "auth_token": newToken,
+2 -1
View File
@@ -148,7 +148,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
} }
<!-- Regenerate Token Button --> <!-- Regenerate Token Button -->
<button <button
@click="regenerateDeviceToken('{ device.ID }', $event)" hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80" class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);" style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
> >
+204 -68
View File
@@ -30,9 +30,15 @@ async function handleAddDevice(event: Event): Promise<void> {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement; const deviceNameInput = document.getElementById(
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement; "device-name",
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement; ) as HTMLInputElement;
const deviceTypeInput = document.getElementById(
"device-type",
) as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById(
"device-identifier",
) as HTMLInputElement;
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return; if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
@@ -53,7 +59,10 @@ async function handleAddDevice(event: Event): Promise<void> {
}); });
if (response.ok) { if (response.ok) {
showToast("Device registered! Check your device for sync instructions.", "success"); showToast(
"Device registered! Check your device for sync instructions.",
"success",
);
hideAddDeviceModal(); hideAddDeviceModal();
window.location.reload(); window.location.reload();
} else { } else {
@@ -67,12 +76,24 @@ async function handleAddDevice(event: Event): Promise<void> {
function showDeviceSettings(deviceId: string): void { function showDeviceSettings(deviceId: string): void {
const modal = document.getElementById("device-settings-modal"); const modal = document.getElementById("device-settings-modal");
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; const deviceIdInput = document.getElementById(
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement; "settings-device-id",
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement; ) as HTMLInputElement;
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement; const deviceTypeInput = document.getElementById(
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; "settings-device-type",
const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement; ) 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; if (!modal || !deviceIdInput) return;
@@ -88,9 +109,11 @@ function showDeviceSettings(deviceId: string): void {
.then((device) => { .then((device) => {
if (deviceTypeInput) deviceTypeInput.value = device.device_type || ""; if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
if (deviceNameInput) deviceNameInput.value = device.device_name || ""; if (deviceNameInput) deviceNameInput.value = device.device_name || "";
if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false; if (syncEnabledInput)
syncEnabledInput.checked = device.sync_enabled || false;
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false; if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
if (syncFrequencyInput) syncFrequencyInput.value = String(device.sync_frequency_minutes || 60); if (syncFrequencyInput)
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
const deviceType = device.device_type; const deviceType = device.device_type;
return fetch("/api/collections", { return fetch("/api/collections", {
@@ -104,17 +127,32 @@ function showDeviceSettings(deviceId: string): void {
const viewSettings = firstCollection.view_settings || {}; const viewSettings = firstCollection.view_settings || {};
const deviceSettings = viewSettings[deviceType] || {}; const deviceSettings = viewSettings[deviceType] || {};
const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement; const viewModeInput = document.getElementById(
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement; "settings-view-mode",
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement; ) as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; const sortOrderInput = document.getElementById(
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement; "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 (viewModeInput)
if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name"; viewModeInput.value = deviceSettings.view_mode || "grid";
if (itemsPerPageInput) itemsPerPageInput.value = String(deviceSettings.items_per_page || 24); if (sortOrderInput)
if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false; sortOrderInput.value = deviceSettings.sort_order || "name";
if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false; 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) => { .catch((error) => {
@@ -125,7 +163,9 @@ function showDeviceSettings(deviceId: string): void {
} }
function showShelfMappings(deviceId: string): void { function showShelfMappings(deviceId: string): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; const mappingsDeviceIdInput = document.getElementById(
"mappings-device-id",
) as HTMLInputElement;
const modal = document.getElementById("shelf-mappings-modal"); const modal = document.getElementById("shelf-mappings-modal");
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId; if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
@@ -180,29 +220,45 @@ async function loadShelfMappings(deviceId: string): Promise<void> {
) )
.join(""); .join("");
} else { } else {
container.innerHTML = '<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>'; container.innerHTML =
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
} }
} catch (error) { } catch (error) {
container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>'; container.innerHTML =
'<p style="color: var(--error)">Failed to load mappings</p>';
} }
} }
function showAddMappingModal(): void { function showAddMappingModal(): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; const mappingsDeviceIdInput = document.getElementById(
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; "mappings-device-id",
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; ) as HTMLInputElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; const mappingIdInput = document.getElementById(
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; "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"); const modal = document.getElementById("add-mapping-modal");
if (mappingsDeviceIdInput) { if (mappingsDeviceIdInput) {
const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; const mappingDeviceIdInput = document.getElementById(
if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value; "mapping-device-id",
) as HTMLInputElement;
if (mappingDeviceIdInput)
mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
} }
if (mappingIdInput) mappingIdInput.value = ""; if (mappingIdInput) mappingIdInput.value = "";
if (mappingCollectionInput) mappingCollectionInput.value = ""; if (mappingCollectionInput) mappingCollectionInput.value = "";
if (mappingShelfNameInput) mappingShelfNameInput.value = ""; if (mappingShelfNameInput) mappingShelfNameInput.value = "";
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional"; if (mappingSyncDirectionInput)
mappingSyncDirectionInput.value = "bidirectional";
loadCollections(); loadCollections();
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
@@ -216,7 +272,9 @@ function hideAddMappingModal(): void {
} }
async function loadCollections(): Promise<void> { async function loadCollections(): Promise<void> {
const select = document.getElementById("mapping-collection") as HTMLSelectElement; const select = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const token = getToken(); const token = getToken();
if (!select || !token) return; if (!select || !token) return;
@@ -229,24 +287,40 @@ async function loadCollections(): Promise<void> {
if (result.collections) { if (result.collections) {
select.innerHTML = select.innerHTML =
'<option value="">Select collection...</option>' + '<option value="">Select collection...</option>' +
result.collections.map((col: any) => `<option value="${col.id}">${col.name}</option>`).join(""); result.collections
.map((col: any) => `<option value="${col.id}">${col.name}</option>`)
.join("");
} }
} catch (error) { } catch (error) {
console.error("Failed to load collections", error); console.error("Failed to load collections", error);
} }
} }
function editMapping(mappingId: string, collectionId: string, shelfName: string, syncDirection: string): void { function editMapping(
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; mappingId: string,
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; collectionId: string,
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; shelfName: string,
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; 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"); const modal = document.getElementById("add-mapping-modal");
if (mappingIdInput) mappingIdInput.value = mappingId; if (mappingIdInput) mappingIdInput.value = mappingId;
if (mappingCollectionInput) mappingCollectionInput.value = collectionId; if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName; if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection; if (mappingSyncDirectionInput)
mappingSyncDirectionInput.value = syncDirection;
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
} }
@@ -255,13 +329,30 @@ async function handleSaveMapping(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
const token = getToken(); const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; const mappingsDeviceIdInput = document.getElementById(
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; "mapping-device-id",
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; ) as HTMLInputElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; const mappingIdInput = document.getElementById(
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; "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; if (
!token ||
!mappingsDeviceIdInput ||
!mappingCollectionInput ||
!mappingShelfNameInput ||
!mappingSyncDirectionInput
)
return;
const deviceId = mappingsDeviceIdInput.value; const deviceId = mappingsDeviceIdInput.value;
const mappingId = mappingIdInput.value; const mappingId = mappingIdInput.value;
@@ -305,16 +396,21 @@ async function deleteMapping(mappingId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this mapping?")) return; if (!confirm("Are you sure you want to delete this mapping?")) return;
const token = getToken(); const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; const mappingsDeviceIdInput = document.getElementById(
"mappings-device-id",
) as HTMLInputElement;
if (!token || !mappingsDeviceIdInput) return; if (!token || !mappingsDeviceIdInput) return;
const deviceId = mappingsDeviceIdInput.value; const deviceId = mappingsDeviceIdInput.value;
try { try {
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, { const response = await fetch(
method: "DELETE", `/api/devices/${deviceId}/collections/${mappingId}`,
headers: { Authorization: `Bearer ${token}` }, {
}); method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
showToast("Mapping deleted", "success"); showToast("Mapping deleted", "success");
@@ -337,12 +433,24 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
const token = getToken(); const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; const settingsDeviceIdInput = document.getElementById(
const settingsDeviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement; "settings-device-id",
const settingsDeviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement; ) as HTMLInputElement;
const settingsSyncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement; const settingsDeviceTypeInput = document.getElementById(
const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; "settings-device-type",
const settingsSyncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement; ) 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; if (!token || !settingsDeviceIdInput || !settingsDeviceNameInput) return;
@@ -353,14 +461,27 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
device_name: settingsDeviceNameInput.value, device_name: settingsDeviceNameInput.value,
sync_enabled: settingsSyncEnabledInput?.checked || false, sync_enabled: settingsSyncEnabledInput?.checked || false,
auto_sync: settingsAutoSyncInput?.checked || false, auto_sync: settingsAutoSyncInput?.checked || false,
sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10), sync_frequency_minutes: parseInt(
settingsSyncFrequencyInput?.value || "60",
10,
),
}; };
const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement; const viewModeInput = document.getElementById(
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement; "settings-view-mode",
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement; ) as HTMLSelectElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; const sortOrderInput = document.getElementById(
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement; "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 = { const viewSettings = {
view_mode: viewModeInput?.value || "grid", view_mode: viewModeInput?.value || "grid",
@@ -413,12 +534,29 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
} }
} }
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> { async function handleRevokeDevice(): Promise<void> {
const token = getToken(); const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; const settingsDeviceIdInput = document.getElementById(
"settings-device-id",
) as HTMLInputElement;
if (!token || !settingsDeviceIdInput) return; if (!token || !settingsDeviceIdInput) return;
if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync.")) 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; const deviceId = settingsDeviceIdInput.value;
@@ -547,7 +685,6 @@ export {
hideShelfMappingsModal, hideShelfMappingsModal,
loadCollections, loadCollections,
loadShelfMappings, loadShelfMappings,
regenerateDeviceToken,
rejectDevice, rejectDevice,
setupEventDelegation, setupEventDelegation,
showAddDeviceModal, showAddDeviceModal,
@@ -573,7 +710,6 @@ Alpine.store("devices", {
hideShelfMappingsModal, hideShelfMappingsModal,
loadCollections, loadCollections,
loadShelfMappings, loadShelfMappings,
regenerateDeviceToken,
rejectDevice, rejectDevice,
setupEventDelegation, setupEventDelegation,
showAddDeviceModal, showAddDeviceModal,