clean up unused endpoints and js, fix more a11y errors

This commit is contained in:
Evan Jarrett
2026-01-17 17:36:22 -06:00
parent 26f049fcbe
commit faf63d8344
20 changed files with 118 additions and 313 deletions
-109
View File
@@ -148,115 +148,6 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
render.JSON(w, r, map[string]bool{"starred": false})
}
// CheckStarHandler checks if current user has starred a repository
type CheckStarHandler struct {
BaseUIHandler
}
func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get authenticated user from middleware
user := middleware.GetUser(r)
if user == nil {
// Not authenticated - return not starred
render.JSON(w, r, map[string]bool{"starred": false})
return
}
// Extract parameters
handle := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
// Resolve owner's handle to DID
ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve handle for check star", "handle", handle, "error", err)
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
return
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
// Note: Error handling moves to the PDS call - if session doesn't exist, GetRecord will fail
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Check if star record exists
rkey := atproto.StarRecordKey(ownerDID, repository)
_, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
// Check if OAuth error - if so, invalidate sessions
if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
// For a read operation, just return not starred instead of error
render.JSON(w, r, map[string]bool{"starred": false})
return
}
starred := err == nil
// Return result
render.JSON(w, r, map[string]bool{"starred": starred})
}
// GetStatsHandler returns repository statistics
type GetStatsHandler struct {
BaseUIHandler
}
func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Extract parameters
handle := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
// Resolve owner's handle to DID
ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
if err != nil {
http.Error(w, "Failed to resolve handle", http.StatusBadRequest)
return
}
// Get repository stats from database
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
if err != nil {
http.Error(w, "Failed to fetch stats", http.StatusInternalServerError)
return
}
// Return stats as JSON
render.JSON(w, r, stats)
}
// ManifestDetailHandler returns detailed manifest information including platforms
type ManifestDetailHandler struct {
BaseUIHandler
}
func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Extract parameters
handle := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
digest := chi.URLParam(r, "digest")
// Resolve owner's handle to DID
ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
if err != nil {
http.Error(w, "Failed to resolve handle", http.StatusBadRequest)
return
}
// Get manifest detail from database
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, ownerDID, repository, digest)
if err != nil {
if err.Error() == "manifest not found" {
http.Error(w, "Manifest not found", http.StatusNotFound)
return
}
slog.Error("Failed to get manifest detail", "error", err)
http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError)
return
}
// Return manifest as JSON
render.JSON(w, r, manifest)
}
// CredentialHelperVersionResponse is the response for the credential helper version API
type CredentialHelperVersionResponse struct {
Latest string `json:"latest"`
+17
View File
@@ -272,6 +272,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// ListDevicesHandler handles GET /api/devices
// Returns HTML partial for HTMX requests, JSON for API requests
type ListDevicesHandler struct {
BaseUIHandler
}
@@ -298,6 +299,22 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get devices for this user
devices := h.DeviceStore.ListDevices(sess.DID)
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
// Return HTML partial
data := struct {
Devices []*db.Device
}{
Devices: devices,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.Templates.ExecuteTemplate(w, "devices-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Return JSON for API requests
render.JSON(w, r, devices)
}
-63
View File
@@ -6,7 +6,6 @@ package handlers
import (
"log"
"net/http"
"strconv"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
@@ -70,65 +69,3 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
// RecentPushesHandler handles the HTMX request for recent repositories
// Note: This endpoint returns repo cards (one per repository) instead of individual pushes
type RecentPushesHandler struct {
BaseUIHandler
}
func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
limit := 20
offset := 0
if o := r.URL.Query().Get("offset"); o != "" {
offset, _ = strconv.Atoi(o)
}
// Get current user DID (empty string if not logged in)
var currentUserDID string
if user := middleware.GetUser(r); user != nil {
currentUserDID = user.DID
}
// Get recent repositories using repo cards (sorted by last update)
repos, err := db.GetRepoCards(h.ReadOnlyDB, limit+offset, currentUserDID, db.SortByLastUpdate)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply offset manually (GetRepoCards doesn't support offset directly)
if offset > 0 && offset < len(repos) {
repos = repos[offset:]
} else if offset >= len(repos) {
repos = []db.RepoCardData{}
}
// Limit results
if len(repos) > limit {
repos = repos[:limit]
}
// Set registry URL on all cards
db.SetRegistryURL(repos, h.RegistryURL)
data := struct {
PageData
Repositories []db.RepoCardData
SearchQuery string
HasMore bool
NextOffset int
}{
PageData: NewPageData(r, h.RegistryURL),
Repositories: repos,
SearchQuery: "",
HasMore: len(repos) == limit,
NextOffset: offset + limit,
}
if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ type RepoOGHandler struct {
func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
repository := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
// Resolve handle to DID
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -22,7 +23,7 @@ type RepositoryPageHandler struct {
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
repository := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
// Resolve identifier (handle or DID) to canonical DID and current handle
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
+22 -12
View File
@@ -17,29 +17,39 @@ type contextKey string
const userKey contextKey = "user"
// handleUnauthenticated handles unauthenticated requests appropriately.
// For HTMX requests, returns 401 to avoid swapping login page content into the DOM.
// For regular requests, redirects to the login page.
func handleUnauthenticated(w http.ResponseWriter, r *http.Request) {
// HTMX requests should get a 401 to trigger client-side handling
// instead of swapping login page content into the current page
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/auth/oauth/login?return_to="+url.QueryEscape(r.URL.Path))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Build return URL with query parameters preserved
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo = r.URL.Path + "?" + r.URL.RawQuery
}
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
}
// RequireAuth is middleware that requires authentication
func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionID, ok := getSessionID(r)
if !ok {
// Build return URL with query parameters preserved
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo = r.URL.Path + "?" + r.URL.RawQuery
}
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
handleUnauthenticated(w, r)
return
}
sess, ok := store.Get(sessionID)
if !ok {
// Build return URL with query parameters preserved
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo = r.URL.Path + "?" + r.URL.RawQuery
}
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
handleUnauthenticated(w, r)
return
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -20
View File
@@ -68,10 +68,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
&uihandlers.HomeHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/recent-pushes", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.RecentPushesHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/search", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.SearchHandler{BaseUIHandler: base},
).ServeHTTP)
@@ -99,11 +95,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
&uihandlers.TermsOfServiceHandler{BaseUIHandler: base},
).ServeHTTP)
// API route for repository stats (public, read-only)
router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.GetStatsHandler{BaseUIHandler: base},
).ServeHTTP)
// API routes for stars (require authentication)
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.StarRepositoryHandler{BaseUIHandler: base},
@@ -113,15 +104,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
&uihandlers.UnstarRepositoryHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.CheckStarHandler{BaseUIHandler: base},
).ServeHTTP)
// Manifest detail API endpoint
router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.ManifestDetailHandler{BaseUIHandler: base},
).ServeHTTP)
// Manifest health check API endpoint (HTMX polling)
router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{BaseUIHandler: base}).ServeHTTP)
@@ -132,9 +114,9 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// OpenGraph image generation (public, cacheable)
router.Get("/og/home", (&uihandlers.DefaultOGHandler{}).ServeHTTP)
router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/og/r/{handle}/*", (&uihandlers.RepoOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/r/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/r/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.RepositoryPageHandler{BaseUIHandler: base},
).ServeHTTP)
+13 -13
View File
@@ -26,14 +26,13 @@ function setTheme(theme) {
}
function updateThemeUI(pref) {
// Update nav button icon to show selected preference
// Update nav button icon to show selected preference (supports multiple toggles)
const iconMap = { system: 'sun-moon', light: 'sun', dark: 'moon' };
const icon = document.getElementById('theme-icon');
if (icon) {
document.querySelectorAll('[data-theme-icon]').forEach(icon => {
icon.setAttribute('data-lucide', iconMap[pref] || 'sun-moon');
if (typeof window.lucide !== 'undefined') {
window.lucide.createIcons();
}
});
if (typeof window.lucide !== 'undefined') {
window.lucide.createIcons();
}
// Update checkmarks in dropdown
@@ -47,9 +46,11 @@ function updateThemeUI(pref) {
}
function closeThemeDropdown() {
const btn = document.getElementById('theme-toggle-btn');
const details = btn?.closest('details');
if (details) details.removeAttribute('open');
// Close all theme dropdowns (supports multiple toggles)
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
const details = btn.closest('details');
if (details) details.removeAttribute('open');
});
}
// Listen for system theme changes
@@ -193,16 +194,15 @@ document.addEventListener('DOMContentLoaded', () => {
applyTheme();
// Theme dropdown setup - DaisyUI details handles open/close natively
const themeMenu = document.getElementById('theme-dropdown-menu');
if (themeMenu) {
// Supports multiple theme menus (e.g., mobile + desktop nav)
document.querySelectorAll('[data-theme-menu]').forEach(themeMenu => {
// Handle theme option clicks
themeMenu.querySelectorAll('.theme-option').forEach(option => {
option.addEventListener('click', () => {
setTheme(option.dataset.value);
});
});
}
});
});
// Update timestamps after HTMX swaps
@@ -1,9 +1,9 @@
{{ define "nav-theme-toggle" }}
<details class="dropdown dropdown-end">
<summary id="theme-toggle-btn" class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
<i data-lucide="sun" id="theme-icon" class="size-5"></i>
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
<i data-lucide="sun" data-theme-icon class="size-5"></i>
</summary>
<ul id="theme-dropdown-menu" class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<ul data-theme-menu class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<li>
<button type="button" class="theme-option" data-value="system">
<i data-lucide="sun-moon" class="size-4"></i>
@@ -20,7 +20,9 @@
<li><a href="/u/{{ .User.Handle }}">Your Repositories</a></li>
<li><a href="/settings">Settings</a></li>
<li class="border-t border-base-300 mt-2 pt-2">
<a href="/auth/logout" class="text-error" onclick="event.preventDefault(); fetch('/auth/logout', {method: 'POST', credentials: 'same-origin'}).then(() => window.location.href = '/');">Logout</a>
<form action="/auth/logout" method="POST" class="m-0">
<button type="submit" class="text-error w-full text-left">Logout</button>
</form>
</li>
</ul>
</details>
@@ -17,7 +17,7 @@
- LastUpdated: time.Time (optional) - Last push time
- RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io")
*/}}
<div class="card card-interactive bg-base-200 border-2 border-base-300 p-6 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ pathEncode .Repository }}">
<div class="card card-interactive bg-base-200 border-2 border-base-300 p-6 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}">
<div class="flex gap-4 items-start">
{{ if .IconURL }}
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .Repository }}" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
@@ -34,7 +34,7 @@
<div class="font-semibold text-sm truncate">
<a href="/u/{{ .OwnerHandle }}" class="link link-primary" onclick="event.stopPropagation()">{{ .OwnerHandle }}</a>
<span class="text-base-content/60">/</span>
<a href="/r/{{ .OwnerHandle }}/{{ pathEncode .Repository }}" class="link text-base-content hover:underline" onclick="event.stopPropagation()">{{ .Repository }}</a>
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="link text-base-content hover:underline" onclick="event.stopPropagation()">{{ .Repository }}</a>
</div>
{{ if .Tag }}
<span class="block text-base-content/60 text-sm truncate">Tag: {{ .Tag }}</span>
+2 -2
View File
@@ -11,9 +11,9 @@
<button class="btn btn-sm gap-2 btn-ghost group border border-transparent hover:border-primary{{ if .IsStarred }} border-amber-400!{{ end }}"
id="star-btn"
{{ if .IsStarred }}
hx-delete="/api/stars/{{ .Handle }}/{{ pathEncode .Repository }}"
hx-delete="/api/stars/{{ .Handle }}/{{ .Repository }}"
{{ else }}
hx-post="/api/stars/{{ .Handle }}/{{ pathEncode .Repository }}"
hx-post="/api/stars/{{ .Handle }}/{{ .Repository }}"
{{ end }}
hx-swap="outerHTML"
hx-on::before-request="this.disabled=true"
+1
View File
@@ -34,6 +34,7 @@
<input type="hidden" name="return_to" value="{{ .ReturnTo }}" />
<fieldset class="fieldset relative">
<legend class="sr-only">Login credentials</legend>
<label class="label" for="handle">
<span class="label-text">Your ATProto Handle</span>
</label>
+7 -7
View File
@@ -4,21 +4,21 @@
<head>
<title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title>
<meta name="description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image {{ .Owner.Handle }}/{{ .Repository.Name }} on ATCR{{ end }}">
<link rel="canonical" href="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ pathEncode .Repository.Name }}">
<link rel="canonical" href="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
<!-- Open Graph -->
<meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
<meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
<meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ pathEncode .Repository.Name }}">
<meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:type" content="website">
<meta property="og:url" content="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ pathEncode .Repository.Name }}">
<meta property="og:url" content="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
<meta property="og:site_name" content="ATCR">
<!-- Twitter Card (used by Discord) -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
<meta name="twitter:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ pathEncode .Repository.Name }}">
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
@@ -26,7 +26,7 @@
"@type": "SoftwareSourceCode",
"name": "{{ .Owner.Handle }}/{{ .Repository.Name }}",
"description": {{ if .Repository.Description }}"{{ .Repository.Description }}"{{ else }}"Container image on ATCR"{{ end }},
"codeRepository": "https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ pathEncode .Repository.Name }}",
"codeRepository": "https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}",
"author": {
"@type": "Person",
"name": "{{ .Owner.Handle }}",
@@ -170,7 +170,7 @@
</time>
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
hx-delete="/api/images/{{ pathEncode $.Repository.Name }}/tags/{{ .Tag.Tag }}"
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag.Tag }}"
hx-confirm="Delete tag {{ .Tag.Tag }}?"
hx-target="#tag-{{ sanitizeID .Tag.Tag }}"
hx-swap="outerHTML"
@@ -256,7 +256,7 @@
</time>
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
onclick="deleteManifest('{{ pathEncode $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"
aria-label="Delete manifest {{ .Manifest.Digest | truncateDigest }}">
<i data-lucide="trash-2" class="size-4"></i>
</button>
+2 -2
View File
@@ -17,9 +17,9 @@
<main class="container mx-auto px-4 py-8">
{{ if .SearchQuery }}
<h2 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h2>
<h1 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h1>
{{ else }}
<h2 class="text-2xl font-bold mb-2">Search</h2>
<h1 class="text-2xl font-bold mb-2">Search</h1>
<p class="text-base-content/60 mb-6">Enter a search term to find images.</p>
{{ end }}
+11 -72
View File
@@ -54,6 +54,7 @@
class="space-y-4">
<fieldset class="fieldset">
<legend class="sr-only">Storage hold selection</legend>
<label class="label" for="default-hold">
<span class="label-text">Storage Hold</span>
</label>
@@ -173,8 +174,11 @@
<th>Actions</th>
</tr>
</thead>
<tbody id="devices-table">
<tr><td colspan="5" class="text-center text-base-content/60">Loading...</td></tr>
<tbody id="devices-table"
hx-get="/api/devices"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<tr><td colspan="5" class="text-center"><i data-lucide="loader-2" class="size-4 animate-spin inline-block"></i> Loading...</td></tr>
</tbody>
</table>
</div>
@@ -307,77 +311,12 @@
}
});
// Device Management JavaScript
(function() {
// Load devices
async function loadDevices() {
try {
const resp = await fetch('/api/devices');
if (!resp.ok) {
throw new Error('Failed to load devices');
}
const devices = await resp.json();
const tbody = document.getElementById('devices-table');
if (devices.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-base-content/60">No authorized devices yet. Follow the setup instructions above!</td></tr>';
return;
}
tbody.innerHTML = devices.map(device => {
const createdDate = new Date(device.created_at).toLocaleDateString();
const lastUsed = device.last_used && device.last_used !== '0001-01-01T00:00:00Z'
? new Date(device.last_used).toLocaleDateString()
: 'Never';
return `
<tr>
<td>${escapeHtml(device.name)}</td>
<td class="font-mono text-sm">${escapeHtml(device.ip_address || 'Unknown')}</td>
<td>${createdDate}</td>
<td>${lastUsed}</td>
<td><button class="btn btn-error btn-xs" onclick="revokeDevice('${device.id}')" aria-label="Revoke access for device ${escapeHtml(device.name)}">Revoke</button></td>
</tr>
`;
}).join('');
} catch (err) {
console.error('Error loading devices:', err);
document.getElementById('devices-table').innerHTML =
'<tr><td colspan="5" class="text-center text-error">Error loading devices</td></tr>';
}
// Reinitialize Lucide icons after HTMX swaps (for device table buttons)
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Revoke device
window.revokeDevice = async function(id) {
if (!confirm('Are you sure you want to revoke this device? This cannot be undone.')) {
return;
}
try {
const resp = await fetch(`/api/devices/${id}`, { method: 'DELETE' });
if (!resp.ok) {
throw new Error('Failed to revoke device');
}
loadDevices();
} catch (err) {
alert('Error revoking device: ' + err.message);
}
};
// Escape HTML helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load devices on page load
loadDevices();
// Refresh devices every 30 seconds (to show new authorizations)
setInterval(loadDevices, 30000);
})();
});
// Account Deletion JavaScript
(function() {
@@ -0,0 +1,21 @@
{{ define "devices-table" }}
{{ range .Devices }}
<tr id="device-{{ .ID }}">
<td>{{ .Name }}</td>
<td class="font-mono text-sm">{{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }}</td>
<td>{{ formatDate .CreatedAt }}</td>
<td>{{ if isZeroTime .LastUsed }}Never{{ else }}{{ formatDate .LastUsed }}{{ end }}</td>
<td>
<button class="btn btn-ghost btn-sm text-error"
hx-delete="/api/devices/{{ .ID }}"
hx-target="#device-{{ .ID }}"
hx-swap="delete"
hx-confirm="Revoke access for {{ .Name }}?">
<i data-lucide="trash-2" class="size-4"></i>
</button>
</td>
</tr>
{{ else }}
<tr><td colspan="5" class="text-center text-base-content/60">No authorized devices yet. Follow the setup instructions above!</td></tr>
{{ end }}
{{ end }}
+6 -2
View File
@@ -154,8 +154,12 @@ func Templates() (*template.Template, error) {
"assetHash": AssetHash,
"pathEncode": func(s string) string {
return url.PathEscape(s)
"formatDate": func(t time.Time) string {
return t.Format("Jan 2, 2006")
},
"isZeroTime": func(t time.Time) bool {
return t.IsZero() || t.Year() < 2000
},
}