add repo page editor. fix deleting all untagged actually deleting all untagged

This commit is contained in:
Evan Jarrett
2026-03-23 21:16:13 -05:00
parent d6816fd00e
commit 23db9be665
16 changed files with 676 additions and 75 deletions
+4
View File
@@ -26,6 +26,10 @@
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 3000000
},
"userEdited": {
"type": "boolean",
"description": "Whether the description was manually edited by the user. When true, auto-population from manifest annotations is skipped on push."
},
"createdAt": {
"type": "string",
"format": "datetime",
@@ -0,0 +1,3 @@
description: Add user_edited flag to repo_pages to prevent auto-overwrite of manually edited descriptions
query: |
ALTER TABLE repo_pages ADD COLUMN user_edited BOOLEAN NOT NULL DEFAULT 0;
+50 -22
View File
@@ -1356,28 +1356,53 @@ func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error)
return tags, nil
}
// GetUntaggedTopLevelManifestDigests returns digests of top-level manifests that have no tags.
// GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion.
// Returns children of untagged manifest lists first (bottom-up) so the handler can delete
// children before parents, avoiding orphaned manifests from cascade-deleted references.
// Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch).
func GetUntaggedTopLevelManifestDigests(db DBTX, did, repository string) ([]string, error) {
func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) {
rows, err := db.Query(`
WITH manifest_list_children AS (
SELECT DISTINCT mr.digest
FROM manifest_references mr
JOIN manifests m ON mr.manifest_id = m.id
WHERE m.did = ? AND m.repository = ?
),
untagged_top_level AS (
SELECT m.id, m.digest,
CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
THEN 1 ELSE 0 END as is_list
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ?
AND (
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
)
GROUP BY m.id
HAVING COUNT(t.tag) = 0
),
untagged_children AS (
SELECT DISTINCT mr.digest
FROM untagged_top_level ul
JOIN manifest_references mr ON ul.id = mr.manifest_id
JOIN manifests child_m ON mr.digest = child_m.digest
AND child_m.did = ? AND child_m.repository = ?
LEFT JOIN tags ct ON child_m.digest = ct.digest
AND child_m.did = ct.did AND child_m.repository = ct.repository
WHERE ul.is_list = 1 AND ct.tag IS NULL
AND mr.digest NOT IN (
SELECT mr2.digest FROM manifest_references mr2
JOIN manifests m2 ON mr2.manifest_id = m2.id
JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository
WHERE m2.did = ? AND m2.repository = ?
)
)
SELECT m.digest
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ?
AND (
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
)
GROUP BY m.id
HAVING COUNT(t.tag) = 0
`, did, repository, did, repository)
SELECT digest FROM untagged_children
UNION ALL
SELECT digest FROM untagged_top_level
`, did, repository, did, repository, did, repository, did, repository)
if err != nil {
return nil, err
}
@@ -2074,22 +2099,25 @@ type RepoPage struct {
Repository string
Description string
AvatarCID string
UserEdited bool
CreatedAt time.Time
UpdatedAt time.Time
}
// UpsertRepoPage inserts or updates a repo page record
func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error {
func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error {
_, err := db.Exec(`
INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository) DO UPDATE SET
description = excluded.description,
avatar_cid = excluded.avatar_cid,
user_edited = excluded.user_edited,
updated_at = excluded.updated_at
WHERE excluded.description IS NOT repo_pages.description
OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
`, did, repository, description, avatarCID, createdAt, updatedAt)
OR excluded.user_edited IS NOT repo_pages.user_edited
`, did, repository, description, avatarCID, userEdited, createdAt, updatedAt)
return err
}
@@ -2097,10 +2125,10 @@ func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, cre
func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) {
var rp RepoPage
err := db.QueryRow(`
SELECT did, repository, description, avatar_cid, created_at, updated_at
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
FROM repo_pages
WHERE did = ? AND repository = ?
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt)
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt)
if err != nil {
return nil, err
}
@@ -2118,7 +2146,7 @@ func DeleteRepoPage(db DBTX, did, repository string) error {
// GetRepoPagesByDID returns all repo pages for a DID
func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
rows, err := db.Query(`
SELECT did, repository, description, avatar_cid, created_at, updated_at
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
FROM repo_pages
WHERE did = ?
`, did)
@@ -2130,7 +2158,7 @@ func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
var pages []RepoPage
for rows.Next() {
var rp RepoPage
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
return nil, err
}
pages = append(pages, rp)
+155
View File
@@ -1376,3 +1376,158 @@ func TestIsManifestReferenced(t *testing.T) {
t.Error("Expected sha256:childdef to NOT be referenced for different user")
}
}
func TestGetAllUntaggedManifestDigests(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
did := "did:plc:test123"
repo := "myapp"
now := time.Now()
if err := UpsertUser(db, &User{
DID: did,
Handle: "test.bsky.social",
PDSEndpoint: "https://test.pds.example.com",
LastSeen: now,
}); err != nil {
t.Fatalf("Failed to insert user: %v", err)
}
indexType := "application/vnd.oci.image.index.v1+json"
manifestType := "application/vnd.oci.image.manifest.v1+json"
hold := "did:web:hold.example.com"
insertManifest := func(t *testing.T, digest, mediaType string) int64 {
t.Helper()
id, err := InsertManifest(db, &Manifest{
DID: did, Repository: repo, Digest: digest,
HoldEndpoint: hold, SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now,
})
if err != nil {
t.Fatalf("Failed to insert manifest %s: %v", digest, err)
}
return id
}
insertRef := func(t *testing.T, parentID int64, childDigest string, idx int) {
t.Helper()
err := InsertManifestReference(db, &ManifestReference{
ManifestID: parentID,
Digest: childDigest,
Size: 1000,
MediaType: manifestType,
PlatformArchitecture: "amd64",
PlatformOS: "linux",
ReferenceIndex: idx,
})
if err != nil {
t.Fatalf("Failed to insert reference: %v", err)
}
}
insertTag := func(t *testing.T, digest, tag string) {
t.Helper()
if err := UpsertTag(db, &Tag{
DID: did, Repository: repo, Tag: tag,
Digest: digest, CreatedAt: now,
}); err != nil {
t.Fatalf("Failed to insert tag: %v", err)
}
}
// Setup scenario:
//
// TAGGED index "sha256:tagged-index" -> tag "v1"
// children: sha256:tagged-child-amd64, sha256:shared-child-arm64
//
// UNTAGGED index "sha256:untagged-index" (no tag)
// children: sha256:untagged-child-amd64, sha256:shared-child-arm64
//
// UNTAGGED orphan single-arch "sha256:orphan-single" (no tag, no parent)
//
// TAGGED single-arch "sha256:tagged-single" -> tag "latest"
// Tagged index + its children
taggedIndexID := insertManifest(t, "sha256:tagged-index", indexType)
insertManifest(t, "sha256:tagged-child-amd64", manifestType)
insertManifest(t, "sha256:shared-child-arm64", manifestType)
insertRef(t, taggedIndexID, "sha256:tagged-child-amd64", 0)
insertRef(t, taggedIndexID, "sha256:shared-child-arm64", 1)
insertTag(t, "sha256:tagged-index", "v1")
// Untagged index + its children
untaggedIndexID := insertManifest(t, "sha256:untagged-index", indexType)
insertManifest(t, "sha256:untagged-child-amd64", manifestType)
// sha256:shared-child-arm64 already inserted, just add the reference
insertRef(t, untaggedIndexID, "sha256:untagged-child-amd64", 0)
insertRef(t, untaggedIndexID, "sha256:shared-child-arm64", 1)
// Orphan single-arch (no parent, no tag)
insertManifest(t, "sha256:orphan-single", manifestType)
// Tagged single-arch
insertManifest(t, "sha256:tagged-single", manifestType)
insertTag(t, "sha256:tagged-single", "latest")
// Run the query
digests, err := GetAllUntaggedManifestDigests(db, did, repo)
if err != nil {
t.Fatalf("GetAllUntaggedManifestDigests error: %v", err)
}
// Build sets for easy checking
digestSet := map[string]bool{}
for _, d := range digests {
digestSet[d] = true
}
// Should include: untagged index, its exclusive child, and the orphan single
if !digestSet["sha256:untagged-index"] {
t.Error("Expected untagged-index to be included")
}
if !digestSet["sha256:untagged-child-amd64"] {
t.Error("Expected untagged-child-amd64 to be included")
}
if !digestSet["sha256:orphan-single"] {
t.Error("Expected orphan-single to be included")
}
// Should NOT include: tagged index, tagged children, shared child (still referenced by tagged index), tagged single
if digestSet["sha256:tagged-index"] {
t.Error("Expected tagged-index to NOT be included")
}
if digestSet["sha256:tagged-child-amd64"] {
t.Error("Expected tagged-child-amd64 to NOT be included")
}
if digestSet["sha256:shared-child-arm64"] {
t.Error("Expected shared-child-arm64 to NOT be included (still referenced by tagged index)")
}
if digestSet["sha256:tagged-single"] {
t.Error("Expected tagged-single to NOT be included")
}
// Verify ordering: children should come before their parent index
childIdx := -1
parentIdx := -1
for i, d := range digests {
if d == "sha256:untagged-child-amd64" {
childIdx = i
}
if d == "sha256:untagged-index" {
parentIdx = i
}
}
if childIdx >= 0 && parentIdx >= 0 && childIdx > parentIdx {
t.Errorf("Expected children before parents: child at index %d, parent at index %d", childIdx, parentIdx)
}
// Verify total count: untagged-child-amd64, orphan-single, untagged-index = 3
if len(digests) != 3 {
t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests)
}
}
+1
View File
@@ -232,6 +232,7 @@ CREATE TABLE IF NOT EXISTS repo_pages (
repository TEXT NOT NULL,
description TEXT,
avatar_cid TEXT,
user_edited BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY(did, repository),
+1 -1
View File
@@ -202,7 +202,7 @@ func (h *DeleteUntaggedManifestsHandler) ServeHTTP(w http.ResponseWriter, r *htt
return
}
digests, err := db.GetUntaggedTopLevelManifestDigests(h.DB, user.DID, req.Repo)
digests, err := db.GetAllUntaggedManifestDigests(h.DB, user.DID, req.Repo)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to query untagged manifests: %v", err), http.StatusInternalServerError)
return
+141
View File
@@ -0,0 +1,141 @@
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
)
// SaveRepoPageHandler saves user-edited description to the PDS repo page record
type SaveRepoPageHandler struct {
BaseUIHandler
}
func (h *SaveRepoPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
did := r.FormValue("did")
repository := r.FormValue("repository")
description := r.FormValue("description")
if did == "" || repository == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// Verify ownership
if user.DID != did {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Size limit
if len(description) > 100*1024 {
http.Error(w, "Description too large (max 100KB)", http.StatusBadRequest)
return
}
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing record to preserve avatar and createdAt
var existingAvatar *atproto.ATProtoBlobRef
var existingCreatedAt time.Time
record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repository)
if err == nil {
var existingRecord atproto.RepoPageRecord
if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil {
existingAvatar = existingRecord.Avatar
existingCreatedAt = existingRecord.CreatedAt
}
} else if !errors.Is(err, atproto.ErrRecordNotFound) {
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
}
// Create updated record
repoPage := atproto.NewRepoPageRecord(repository, description, existingAvatar)
if !existingCreatedAt.IsZero() {
repoPage.CreatedAt = existingCreatedAt
}
// If description is empty, clear userEdited so auto-populate resumes
repoPage.UserEdited = description != ""
// Save to PDS
_, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repository, repoPage)
if err != nil {
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, "Failed to save description", http.StatusInternalServerError)
return
}
// Update DB cache
avatarCID := ""
if existingAvatar != nil && existingAvatar.Ref.Link != "" {
avatarCID = existingAvatar.Ref.Link
}
if err := db.UpsertRepoPage(h.DB, user.DID, repository, description, avatarCID, repoPage.UserEdited, repoPage.CreatedAt, repoPage.UpdatedAt); err != nil {
slog.Warn("Failed to update repo page cache", "error", err)
}
// Return rendered HTML for HTMX swap
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if description == "" {
_, _ = w.Write([]byte(`<p class="text-base-content/60">No description available</p>`))
return
}
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(description))
if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(html))
return
}
w.WriteHeader(http.StatusOK)
}
// PreviewMarkdownHandler renders markdown to HTML for the editor preview tab
type PreviewMarkdownHandler struct {
BaseUIHandler
}
func (h *PreviewMarkdownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
markdown := r.FormValue("markdown")
if markdown == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<p class="text-base-content/60">Nothing to preview</p>`))
return
}
if len(markdown) > 100*1024 {
http.Error(w, "Content too large", http.StatusBadRequest)
return
}
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(markdown))
if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
+36 -25
View File
@@ -122,6 +122,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Fetch README content from repo page record or annotations
var readmeHTML template.HTML
var rawDescription string
repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository)
if err == nil && repoPage != nil {
@@ -129,6 +130,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID)
}
if repoPage.Description != "" && h.ReadmeFetcher != nil {
rawDescription = repoPage.Description
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description))
if err != nil {
slog.Warn("Failed to render repo page description", "error", err)
@@ -146,11 +148,18 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
}
if readmeURL != "" {
html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL)
if err != nil {
slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err)
// Fetch raw markdown for editor pre-fill, then render
rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL)
if fetchErr != nil {
slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr)
} else {
readmeHTML = template.HTML(html)
rawDescription = string(rawBytes)
html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes)
if renderErr != nil {
slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr)
} else {
readmeHTML = template.HTML(html)
}
}
}
}
@@ -181,28 +190,30 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
data := struct {
PageData
Meta *PageMeta
Owner *db.User
Repository *db.Repository
LatestTag string
StarCount int
PullCount int
IsStarred bool
IsOwner bool
ReadmeHTML template.HTML
ArtifactType string
Meta *PageMeta
Owner *db.User
Repository *db.Repository
LatestTag string
StarCount int
PullCount int
IsStarred bool
IsOwner bool
ReadmeHTML template.HTML
RawDescription string
ArtifactType string
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repo,
LatestTag: latestTagName,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
ArtifactType: artifactType,
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repo,
LatestTag: latestTagName,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
RawDescription: rawDescription,
ArtifactType: artifactType,
}
if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+3 -3
View File
@@ -635,8 +635,8 @@ func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did,
}
for _, page := range repoPages {
// Skip pages that already have a description
if page.Description != "" {
// Skip pages that were manually edited by the user or already have a description
if page.UserEdited || page.Description != "" {
continue
}
@@ -668,7 +668,7 @@ func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did,
}
// Always update database with the fetched content
if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil {
if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, false, page.CreatedAt, time.Now()); err != nil {
slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err)
} else if !pdsUpdated {
slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository)
+1 -1
View File
@@ -560,7 +560,7 @@ func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string
}
// Upsert to database
return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt)
return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.UserEdited, pageRecord.CreatedAt, pageRecord.UpdatedAt)
}
// ProcessIdentity handles identity change events (handle updates)
+8
View File
@@ -6,12 +6,14 @@
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
<symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
<symbol id="code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol>
<symbol id="compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/></symbol>
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
@@ -25,8 +27,14 @@
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
<symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol>
<symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol>
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
<symbol id="italic" viewBox="0 0 24 24"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></symbol>
<symbol id="link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
<symbol id="list" viewBox="0 0 24 24"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></symbol>
<symbol id="list-ordered" viewBox="0 0 24 24"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/></symbol>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+4
View File
@@ -177,6 +177,10 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
r.Delete("/api/manifests/untagged", (&uihandlers.DeleteUntaggedManifestsHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP)
// Repository page editing
r.Post("/api/repo-page", (&uihandlers.SaveRepoPageHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/repo-page/preview", (&uihandlers.PreviewMarkdownHandler{BaseUIHandler: base}).ServeHTTP)
// Webhook management
r.Get("/api/webhooks", (&uihandlers.WebhooksHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/webhooks", (&uihandlers.AddWebhookHandler{BaseUIHandler: base}).ServeHTTP)
+25 -16
View File
@@ -501,8 +501,7 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
// ensureRepoPage creates or updates a repo page record in the user's PDS
// This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection
// Always updates the description on push (since users can't edit it via appview yet)
// Preserves user's avatar if they've set one via the appview
// Preserves user's avatar and skips description overwrite if user has manually edited it
func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
rkey := s.ctx.Repository
@@ -525,31 +524,41 @@ func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atpr
slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
}
// Get annotations (may be nil if image has no OCI labels)
annotations := manifestRecord.Annotations
if annotations == nil {
annotations = make(map[string]string)
}
// If user has manually edited the description, preserve it
userEdited := existingRecord != nil && existingRecord.UserEdited
var description string
if userEdited {
description = existingRecord.Description
} else {
// Get annotations (may be nil if image has no OCI labels)
annotations := manifestRecord.Annotations
if annotations == nil {
annotations = make(map[string]string)
}
// Try to fetch README content from external sources
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description
description := s.fetchReadmeContent(ctx, annotations)
// Try to fetch README content from external sources
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description
description = s.fetchReadmeContent(ctx, annotations)
// If no README content could be fetched, fall back to description annotation
if description == "" {
description = annotations["org.opencontainers.image.description"]
// If no README content could be fetched, fall back to description annotation
if description == "" {
description = annotations["org.opencontainers.image.description"]
}
}
// Determine avatar: prefer new icon from annotations, otherwise keep existing
avatarRef := existingAvatarRef
if iconURL := annotations["io.atcr.icon"]; iconURL != "" {
if newAvatar := s.fetchAndUploadIcon(ctx, iconURL); newAvatar != nil {
avatarRef = newAvatar
if !userEdited && manifestRecord.Annotations != nil {
if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" {
if newAvatar := s.fetchAndUploadIcon(ctx, iconURL); newAvatar != nil {
avatarRef = newAvatar
}
}
}
// Create/update repo page record with description and avatar
repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef)
repoPage.UserEdited = userEdited
isUpdate := existingRecord != nil
action := "Creating"
+232 -7
View File
@@ -112,16 +112,241 @@
<!-- Tab Panels -->
<!-- Overview Panel -->
<div id="tab-overview" class="repo-panel">
{{ if .ReadmeHTML }}
<div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
<div class="prose prose-sm max-w-none">
{{ .ReadmeHTML }}
<!-- View mode -->
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
{{ if .IsOwner }}
<div class="flex justify-end">
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
{{ icon "pencil" "size-4" }}
Edit
</button>
</div>
{{ end }}
<div id="overview-rendered" class="prose prose-sm max-w-none">
{{ if .ReadmeHTML }}
{{ .ReadmeHTML }}
{{ else }}
<p class="text-base-content/60">No description available</p>
{{ end }}
</div>
</div>
{{ else }}
<div class="card bg-base-100 shadow-sm p-6">
<p class="text-base-content/60">No description available</p>
<!-- Edit mode (hidden, owner only) -->
{{ if .IsOwner }}
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
<!-- Write/Preview tabs -->
<div class="border-b border-base-300 mb-4">
<nav class="flex gap-0" role="tablist">
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
data-tab="write" onclick="switchEditorTab('write')">
Write
</button>
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
data-tab="preview" onclick="switchEditorTab('preview')">
Preview
</button>
</nav>
</div>
<!-- Write panel -->
<div id="editor-write" class="editor-panel">
<!-- Toolbar -->
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
{{ icon "heading" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
{{ icon "bold" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
{{ icon "italic" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
{{ icon "link" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
{{ icon "image" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
{{ icon "list" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
{{ icon "list-ordered" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
{{ icon "code" "size-4" }}
</button>
</div>
<textarea id="md-editor"
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
rows="20"
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
</div>
<!-- Preview panel -->
<div id="editor-preview" class="editor-panel hidden">
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
<p class="text-base-content/60">Nothing to preview</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 mt-4">
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
</div>
</div>
<script>
(function() {
var textarea = document.getElementById('md-editor');
if (!textarea) return;
var ownerDID = {{ .Owner.DID }};
var repoName = {{ .Repository.Name }};
window.toggleOverviewEditor = function(show) {
document.getElementById('overview-view').classList.toggle('hidden', show);
document.getElementById('overview-edit').classList.toggle('hidden', !show);
if (show) textarea.focus();
};
window.switchEditorTab = function(tab) {
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
document.querySelectorAll('.editor-tab').forEach(function(t) {
var active = t.dataset.tab === tab;
t.classList.toggle('border-primary', active);
t.classList.toggle('text-primary', active);
t.classList.toggle('border-transparent', !active);
t.classList.toggle('text-base-content/60', !active);
});
if (tab === 'preview') {
var content = textarea.value;
var previewEl = document.getElementById('preview-content');
if (!content.trim()) {
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
return;
}
var form = new FormData();
form.append('markdown', content);
fetch('/api/repo-page/preview', { method: 'POST', body: form })
.then(function(r) { return r.text(); })
.then(function(html) { previewEl.innerHTML = html; });
}
};
window.insertMd = function(type) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.substring(start, end);
var before = textarea.value.substring(0, start);
var after = textarea.value.substring(end);
var insert, cursorStart, cursorEnd;
switch (type) {
case 'heading':
insert = '## ' + (selected || 'Heading');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'bold':
insert = '**' + (selected || 'bold text') + '**';
cursorStart = start + 2;
cursorEnd = start + insert.length - 2;
break;
case 'italic':
insert = '_' + (selected || 'italic text') + '_';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
break;
case 'link':
insert = '[' + (selected || 'link text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'image':
insert = '![' + (selected || 'alt text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'ul':
insert = '- ' + (selected || 'list item');
cursorStart = start + 2;
cursorEnd = start + insert.length;
break;
case 'ol':
insert = '1. ' + (selected || 'list item');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'code':
if (selected && selected.indexOf('\n') !== -1) {
insert = '```\n' + selected + '\n```';
cursorStart = start + 4;
cursorEnd = start + 4 + selected.length;
} else {
insert = '`' + (selected || 'code') + '`';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
}
break;
default:
return;
}
textarea.value = before + insert + after;
textarea.focus();
textarea.selectionStart = cursorStart;
textarea.selectionEnd = cursorEnd;
};
window.saveOverview = function() {
var btn = document.getElementById('save-overview-btn');
btn.classList.add('btn-disabled');
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
var form = new FormData();
form.append('did', ownerDID);
form.append('repository', repoName);
form.append('description', textarea.value);
fetch('/api/repo-page', {
method: 'POST',
body: form,
headers: { 'HX-Request': 'true' }
})
.then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
})
.then(function(html) {
document.getElementById('overview-rendered').innerHTML = html;
toggleOverviewEditor(false);
if (typeof showToast === 'function') showToast('Overview saved', 'success');
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
})
.finally(function() {
btn.classList.remove('btn-disabled');
btn.innerHTML = 'Save';
});
};
// Ctrl+S / Cmd+S to save
textarea.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveOverview();
}
});
})();
</script>
{{ end }}
</div>
+4
View File
@@ -378,6 +378,10 @@ type RepoPageRecord struct {
// Avatar is the repository avatar/icon blob reference
Avatar *ATProtoBlobRef `json:"avatar,omitempty"`
// UserEdited indicates the description was manually edited by the user
// When true, auto-population from manifest annotations is skipped on push
UserEdited bool `json:"userEdited,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
+8
View File
@@ -6,12 +6,14 @@
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
<symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
<symbol id="code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol>
<symbol id="compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/></symbol>
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
@@ -25,8 +27,14 @@
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
<symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol>
<symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol>
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
<symbol id="italic" viewBox="0 0 24 24"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></symbol>
<symbol id="link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
<symbol id="list" viewBox="0 0 24 24"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></symbol>
<symbol id="list-ordered" viewBox="0 0 24 24"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/></symbol>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB