mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
clean up unused endpoints and js, fix more a11y errors
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user