Skip to content

Commit 956fa67

Browse files
authored
feat(ui): add enhanced detail views and copy-to-clipboard (#373)
## Summary Phase 1 of enhanced detail views - using currently available data: - Add **Email** and **Description** fields to user detail view - Add **Email**, **Description**, and **Groups count** to home screen (logged-in user info) - Add **copy-to-clipboard** button next to DN values on all detail pages (users, groups, computers) ## Changes ### Copy-to-clipboard feature - New `Copyable` component in `icons.templ` that wraps text with a copy button - New `copy-clipboard.js` module for clipboard functionality with visual feedback - Added copy/check icons for the copy button states ### User detail (`users.templ`) - Display Email with mailto link - Display Description ### Home screen (`index.templ`) - Show user's Email - Show user's Description - Show Groups count ### All detail pages - DN values now have a copy icon that copies the full DN to clipboard ## Screenshots The copy button appears as a small clipboard icon next to DN values. When clicked, it shows a checkmark briefly to confirm the copy. ## Related - Part of enhanced detail views feature - Phase 2 (group/computer enhanced fields) requires simple-ldap-go PR #69 to merge first ## Test plan - [x] Build succeeds - [x] Templ generation works - [ ] Manual testing of copy-to-clipboard functionality - [ ] Verify Email/Description display on user detail and home pages
2 parents d7a4e75 + 9105de5 commit 956fa67

File tree

11 files changed

+305
-29
lines changed

11 files changed

+305
-29
lines changed

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/gofiber/storage/bbolt/v2 v2.1.2
99
github.com/gofiber/storage/memory/v2 v2.1.1
1010
github.com/joho/godotenv v1.5.1
11-
github.com/netresearch/simple-ldap-go v1.6.0
11+
github.com/netresearch/simple-ldap-go v1.8.0
1212
github.com/playwright-community/playwright-go v0.5200.1
1313
github.com/rs/zerolog v1.34.0
1414
github.com/stretchr/testify v1.11.1
@@ -67,7 +67,7 @@ require (
6767
github.com/rivo/uniseg v0.4.7 // indirect
6868
github.com/shirou/gopsutil/v4 v4.25.8 // indirect
6969
github.com/sirupsen/logrus v1.9.3 // indirect
70-
github.com/testcontainers/testcontainers-go/modules/openldap v0.39.0 // indirect
70+
github.com/testcontainers/testcontainers-go/modules/openldap v0.40.0 // indirect
7171
github.com/tinylib/msgp v1.2.5 // indirect
7272
github.com/tklauser/go-sysconf v0.3.15 // indirect
7373
github.com/tklauser/numcpus v0.10.0 // indirect
@@ -82,6 +82,6 @@ require (
8282
go.opentelemetry.io/otel/trace v1.38.0 // indirect
8383
golang.org/x/crypto v0.45.0 // indirect
8484
golang.org/x/sys v0.38.0 // indirect
85-
golang.org/x/text v0.31.0 // indirect
85+
golang.org/x/text v0.32.0 // indirect
8686
gopkg.in/yaml.v3 v3.0.1 // indirect
8787
)

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
135135
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
136136
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
137137
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
138-
github.com/netresearch/simple-ldap-go v1.6.0 h1:7OWoLpqxmPBnrqrOLDvgUTj0NFShao4Bo7EIXNUNZLs=
139-
github.com/netresearch/simple-ldap-go v1.6.0/go.mod h1:mmAnwl/UvWreAD9W96N2D3xHYfupm6mlE5uNe73GIIk=
138+
github.com/netresearch/simple-ldap-go v1.8.0 h1:eQG0y2/eHPYjOxAyh5//DDN9SCkUO0lf7GqL/oT631E=
139+
github.com/netresearch/simple-ldap-go v1.8.0/go.mod h1:Qx2OKwlIfykMU9JNDwjVLMcWHzOQbeqRrNF6oGTDB0A=
140140
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
141141
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
142142
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -173,8 +173,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
173173
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
174174
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
175175
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
176-
github.com/testcontainers/testcontainers-go/modules/openldap v0.39.0 h1:Gvgq9rbrzKSnTRBdbLfIozqKIis3qqNmnMglPR57ruw=
177-
github.com/testcontainers/testcontainers-go/modules/openldap v0.39.0/go.mod h1:3e2V7wIuMe1jF/GRvDcGMW09en4GAkKe5gMfbMXPFWQ=
176+
github.com/testcontainers/testcontainers-go/modules/openldap v0.40.0 h1:Uk+OLN+KX/wzBUsBBIyIJNDIBA2wIXDdhPHOiFx+vs4=
177+
github.com/testcontainers/testcontainers-go/modules/openldap v0.40.0/go.mod h1:NjtNXjPNHkRdouEtV7ieCF+FvhbAxm8lg1eVBgRPtmg=
178178
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
179179
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
180180
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
@@ -231,8 +231,8 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
231231
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
232232
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
233233
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
234-
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
235-
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
234+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
235+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
236236
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
237237
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
238238
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -264,8 +264,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
264264
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
265265
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
266266
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
267-
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
268-
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
267+
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
268+
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
269269
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
270270
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
271271
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

internal/ldap_cache/manager.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ type FullLDAPUser struct {
5959
// This provides a complete view of group data including all member users.
6060
type FullLDAPGroup struct {
6161
ldap.Group
62-
Members []ldap.User // All users that belong to this group
62+
Members []ldap.User // All users that belong to this group
63+
ParentGroups []ldap.Group // All groups this group belongs to (from MemberOf)
6364
}
6465

6566
// FullLDAPComputer represents a computer with populated group memberships.
@@ -436,12 +437,14 @@ func (m *Manager) PopulateGroupsForUser(user *ldap.User) *FullLDAPUser {
436437

437438
// PopulateUsersForGroup creates a FullLDAPGroup with populated member list.
438439
// Takes a group and resolves all member DNs to full user objects from the cache.
440+
// Also resolves parent groups from the MemberOf field.
439441
// When showDisabled is false, filters out disabled users from membership.
440-
// Returns a complete group object with expanded member information.
442+
// Returns a complete group object with expanded member and parent group information.
441443
func (m *Manager) PopulateUsersForGroup(group *ldap.Group, showDisabled bool) *FullLDAPGroup {
442444
full := &FullLDAPGroup{
443-
Group: *group,
444-
Members: make([]ldap.User, 0),
445+
Group: *group,
446+
Members: make([]ldap.User, 0),
447+
ParentGroups: make([]ldap.Group, 0),
445448
}
446449

447450
for _, userDN := range group.Members {
@@ -455,6 +458,14 @@ func (m *Manager) PopulateUsersForGroup(group *ldap.Group, showDisabled bool) *F
455458
}
456459
}
457460

461+
// Resolve parent groups from MemberOf
462+
for _, parentDN := range group.MemberOf {
463+
parentGroup, err := m.FindGroupByDN(parentDN)
464+
if err == nil {
465+
full.ParentGroups = append(full.ParentGroups, *parentGroup)
466+
}
467+
}
468+
458469
return full
459470
}
460471

internal/web/server.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,11 @@ func (a *App) indexHandler(c *fiber.Ctx) error {
368368
return handle500(c, err)
369369
}
370370

371+
// Populate groups for the home screen
372+
fullUser := a.ldapCache.PopulateGroupsForUser(user)
373+
371374
// Use template caching
372-
return a.templateCache.RenderWithCache(c, templates.Index(user))
375+
return a.templateCache.RenderWithCache(c, templates.Index(fullUser))
373376
}
374377

375378
func (a *App) fourOhFourHandler(c *fiber.Ctx) error {

internal/web/static/js/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { initThemeToggle, initDensityToggle } from "./toggles.js";
66
import { initComboboxes } from "./combobox.js";
77
import { initSearchFilters } from "./search-filter.js";
8+
import { initCopyButtons } from "./copy-clipboard.js";
89
/**
910
* Initialize all application functionality.
1011
*/
@@ -13,6 +14,7 @@ function init() {
1314
initDensityToggle();
1415
initComboboxes();
1516
initSearchFilters();
17+
initCopyButtons();
1618
}
1719
// Wait for DOM to be ready
1820
if (document.readyState === "loading") {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copy-to-clipboard functionality for copyable text elements.
3+
* Uses the Clipboard API with visual feedback.
4+
*/
5+
6+
/**
7+
* Initialize all copyable elements on the page.
8+
*/
9+
export function initCopyButtons() {
10+
const copyables = document.querySelectorAll("[data-copyable]");
11+
copyables.forEach(initCopyable);
12+
}
13+
14+
/**
15+
* Initialize a single copyable element.
16+
* @param {HTMLElement} element - The copyable container element
17+
*/
18+
function initCopyable(element) {
19+
// Prevent duplicate initialization
20+
if (element.hasAttribute("data-copy-initialized")) {
21+
return;
22+
}
23+
element.setAttribute("data-copy-initialized", "true");
24+
25+
const button = element.querySelector("[data-copy-button]");
26+
const textElement = element.querySelector("[data-copy-text]");
27+
const copyIcon = element.querySelector("[data-copy-icon]");
28+
const checkIcon = element.querySelector("[data-check-icon]");
29+
30+
if (!button || !textElement) return;
31+
32+
// Store timeout ID for cleanup on rapid clicks
33+
let resetTimeout = null;
34+
35+
button.addEventListener("click", async () => {
36+
const text = textElement.textContent?.trim() || "";
37+
38+
// Clear any pending timeout from previous click
39+
if (resetTimeout) {
40+
clearTimeout(resetTimeout);
41+
resetTimeout = null;
42+
}
43+
44+
try {
45+
await navigator.clipboard.writeText(text);
46+
showSuccessFeedback(copyIcon, checkIcon, (timeoutId) => {
47+
resetTimeout = timeoutId;
48+
});
49+
} catch (err) {
50+
console.error("Failed to copy text:", err);
51+
// Fallback for older browsers
52+
const success = fallbackCopy(text);
53+
if (success) {
54+
showSuccessFeedback(copyIcon, checkIcon, (timeoutId) => {
55+
resetTimeout = timeoutId;
56+
});
57+
}
58+
}
59+
});
60+
}
61+
62+
/**
63+
* Show success feedback by toggling icons.
64+
* @param {HTMLElement|null} copyIcon - The copy icon element
65+
* @param {HTMLElement|null} checkIcon - The check icon element
66+
* @param {function} onTimeout - Callback to store the timeout ID
67+
*/
68+
function showSuccessFeedback(copyIcon, checkIcon, onTimeout) {
69+
if (copyIcon && checkIcon) {
70+
copyIcon.classList.add("hidden");
71+
checkIcon.classList.remove("hidden");
72+
73+
// Reset after 2 seconds
74+
const timeoutId = setTimeout(() => {
75+
copyIcon.classList.remove("hidden");
76+
checkIcon.classList.add("hidden");
77+
}, 2000);
78+
79+
if (onTimeout) {
80+
onTimeout(timeoutId);
81+
}
82+
}
83+
}
84+
85+
/**
86+
* Fallback copy method for browsers without Clipboard API.
87+
* @param {string} text - The text to copy
88+
* @returns {boolean} - Whether the copy was successful
89+
*/
90+
function fallbackCopy(text) {
91+
const textarea = document.createElement("textarea");
92+
textarea.value = text;
93+
textarea.style.position = "fixed";
94+
textarea.style.opacity = "0";
95+
document.body.appendChild(textarea);
96+
textarea.select();
97+
let success = false;
98+
try {
99+
success = document.execCommand("copy");
100+
} catch (err) {
101+
console.error("Fallback copy failed:", err);
102+
}
103+
document.body.removeChild(textarea);
104+
return success;
105+
}

internal/web/templates/computer.templ

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package templates
22

33
import "fmt"
44
import "net/url"
5+
import "time"
56
import "github.com/netresearch/ldap-manager/internal/ldap_cache"
67
import "github.com/netresearch/simple-ldap-go"
78

@@ -32,14 +33,49 @@ templ Computer(computer *ldap_cache.FullLDAPComputer) {
3233
@loggedIn(string(computerUrl(computer.Computer)), computer.CN(), []Flash{}) {
3334
<h1 class="text-3xl">{ computer.CN() } ({ computer.SAMAccountName })</h1>
3435
<p class="text-sm text-text-secondary">
35-
{ computer.DN() }
36+
@Copyable(computer.DN())
3637
if !computer.Enabled {
3738
@lockIcon()
3839
}
3940
</p>
40-
<h2 class="mt-4 text-xl">Details:</h2>
41-
<p>Operating system: { computer.OS }</p>
42-
<p>Operating system version: { computer.OSVersion }</p>
41+
<div class="mt-4 grid gap-2 sm:grid-cols-2">
42+
if computer.Description != "" {
43+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
44+
<span class="text-sm text-text-secondary">Description</span>
45+
<p class="font-medium">{ computer.Description }</p>
46+
</div>
47+
}
48+
if computer.DNSHostName != "" {
49+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
50+
<span class="text-sm text-text-secondary">DNS Hostname</span>
51+
<p class="font-medium">{ computer.DNSHostName }</p>
52+
</div>
53+
}
54+
if computer.OS != "" {
55+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
56+
<span class="text-sm text-text-secondary">Operating System</span>
57+
<p class="font-medium">{ computer.OS }</p>
58+
</div>
59+
}
60+
if computer.OSVersion != "" {
61+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
62+
<span class="text-sm text-text-secondary">OS Version</span>
63+
<p class="font-medium">{ computer.OSVersion }</p>
64+
</div>
65+
}
66+
if computer.ServicePack != "" {
67+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
68+
<span class="text-sm text-text-secondary">Service Pack</span>
69+
<p class="font-medium">{ computer.ServicePack }</p>
70+
</div>
71+
}
72+
if computer.LastLogon > 0 {
73+
<div class="rounded-md border border-border bg-surface-elevated px-3 py-2">
74+
<span class="text-sm text-text-secondary">Last Logon</span>
75+
<p class="font-medium">{ formatLastLogon(computer.LastLogon) }</p>
76+
</div>
77+
}
78+
</div>
4379
<h2 class="mt-4 text-xl">Groups:</h2>
4480
@list(specializeGroups(computer.Groups))
4581
if len(computer.Groups) == 0 {
@@ -88,3 +124,11 @@ func computerUrl(computer ldap.Computer) templ.SafeURL {
88124
// Browsers convert backslashes to forward slashes in URLs, so we need encoding
89125
return templ.SafeURL("/computers/" + url.PathEscape(computer.DN()))
90126
}
127+
128+
func formatLastLogon(timestamp int64) string {
129+
if timestamp <= 0 {
130+
return "Never"
131+
}
132+
t := time.Unix(timestamp, 0)
133+
return t.Format("2006-01-02 15:04:05")
134+
}

internal/web/templates/groups.templ

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,31 @@ func specializeGroups(groups []ldap.Group) []Displayer {
3131
templ Group(group *ldap_cache.FullLDAPGroup, unassignedUsers []ldap.User, flashes []Flash, csrfToken string) {
3232
@loggedIn(string(groupUrl(group.Group)), group.CN(), flashes) {
3333
<h1 class="text-3xl">{ group.CN() }</h1>
34-
<p class="text-sm text-text-secondary">{ group.DN() }</p>
35-
<h2 class="mt-4 text-xl">Members:</h2>
34+
<p class="text-sm text-text-secondary">@Copyable(group.DN())</p>
35+
if group.Description != "" {
36+
<div class="mt-4 rounded-md border border-border bg-surface-elevated px-3 py-2">
37+
<span class="text-sm text-text-secondary">Description</span>
38+
<p class="font-medium">{ group.Description }</p>
39+
</div>
40+
}
41+
if len(group.MemberOf) > 0 {
42+
<h2 class="mt-4 text-xl">{ fmt.Sprintf("%d Parent Groups:", len(group.MemberOf)) }</h2>
43+
<div class="list-container">
44+
for _, parentGroup := range group.ParentGroups {
45+
<div class="list-row">
46+
<a
47+
href={ groupUrl(parentGroup) }
48+
class="list-link"
49+
title={ "View group details: " + parentGroup.CN() }
50+
>
51+
<span>{ parentGroup.CN() }</span>
52+
@rightArrowIcon()
53+
</a>
54+
</div>
55+
}
56+
</div>
57+
}
58+
<h2 class="mt-4 text-xl">{ fmt.Sprintf("%d Direct Members:", len(group.Members)) }</h2>
3659
<div class="list-container">
3760
for _, user := range group.Members {
3861
<div class="list-row">

internal/web/templates/icons.templ

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,37 @@ templ sparklesIcon() {
168168
<path d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z"></path>
169169
</svg>
170170
}
171+
172+
templ copyIcon() {
173+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="inline-block h-4 w-4">
174+
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z"></path>
175+
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z"></path>
176+
</svg>
177+
}
178+
179+
templ checkIcon() {
180+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="inline-block h-4 w-4">
181+
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd"></path>
182+
</svg>
183+
}
184+
185+
// Copyable renders text with a copy-to-clipboard button
186+
templ Copyable(text string) {
187+
<span class="inline-flex items-center gap-1" data-copyable>
188+
<span data-copy-text>{ text }</span>
189+
<button
190+
type="button"
191+
class="text-text-secondary hover:text-accent transition-colors cursor-pointer"
192+
data-copy-button
193+
title="Copy to clipboard"
194+
aria-label="Copy to clipboard"
195+
>
196+
<span data-copy-icon>
197+
@copyIcon()
198+
</span>
199+
<span data-check-icon class="hidden text-green-500">
200+
@checkIcon()
201+
</span>
202+
</button>
203+
</span>
204+
}

0 commit comments

Comments
 (0)