mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
add repo page editor. fix deleting all untagged actually deleting all untagged
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = '';
|
||||
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>
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user