Link editor

This commit is contained in:
Asaf Gartner 2024-07-01 02:55:21 +03:00
parent ba86da3374
commit 908c8b02f8
10 changed files with 244 additions and 161 deletions

View File

@ -0,0 +1,48 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v5"
)
func init() {
registerMigration(AddPrimaryToLinks{})
}
type AddPrimaryToLinks struct{}
func (m AddPrimaryToLinks) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2024, 6, 30, 23, 36, 30, 0, time.UTC))
}
func (m AddPrimaryToLinks) Name() string {
return "AddPrimaryToLinks"
}
func (m AddPrimaryToLinks) Description() string {
return "Adds 'primary_link' field to links"
}
func (m AddPrimaryToLinks) Up(ctx context.Context, tx pgx.Tx) error {
utils.Must1(tx.Exec(ctx,
`
ALTER TABLE link
ADD COLUMN primary_link BOOLEAN NOT NULL DEFAULT false;
`,
))
return nil
}
func (m AddPrimaryToLinks) Down(ctx context.Context, tx pgx.Tx) error {
utils.Must1(tx.Exec(ctx,
`
ALTER TABLE link
DROP COLUMN primary_link;
`,
))
return nil
}

View File

@ -5,6 +5,7 @@ type Link struct {
Name string `db:"name"`
URL string `db:"url"`
Ordering int `db:"ordering"`
Primary bool `db:"primary_link"`
UserID *int `db:"user_id"`
ProjectID *int `db:"project_id"`
}

View File

@ -335,6 +335,7 @@ func LinkToTemplate(link *models.Link) Link {
ServiceName: service.Name,
Icon: service.IconName,
Username: username,
Primary: link.Primary,
}
}

View File

@ -0,0 +1,177 @@
<fieldset>
<legend class="flex justify-between">
<span>Links</span>
<a href="#" class="normal" onclick="addLink(event)">+ Add Link</a>
</legend>
<div class="pa3 input-group">
<div id="links" class="flex flex-column g2 relative">
<div>Primary Links</div>
<div class="drop_slot secondary_links">Secondary Links</div>
</div>
<template id="link_row">
<div class="link_row drop_slot w-100 flex flex-row items-center" data-tmpl="root">
<span class="link_handle svgicon pr3 pointer grab" onmousedown="startLinkDrag(event)">{{ svg "draggable" }}</span>
<input data-tmpl="nameInput" class="link_name mr3 w5" type="text" placeholder="Name" />
<input data-tmpl="urlInput" class="link_url flex-grow-1" type="url" placeholder="Link" />
<a class="delete_link svgicon link-normal pl3 f3" href="javascript:;" onclick="deleteLink(event)">{{ svg "delete" }}</a>
</div>
</template>
<template id="link_row_dummy">
<div class="link_row_dummy drop_slot flex flex-row" data-tmpl="root">
<input class="o-0">
</div>
</template>
</div>
<input id="links_json" type="hidden" name="links">
<script>
const linksContainer = document.querySelector("#links");
const parentForm = linksContainer.closest("form");
const secondaryLinksTitle = linksContainer.querySelector(".secondary_links");
const linksJSONInput = document.querySelector("#links_json");
const linkTemplate = makeTemplateCloner("link_row");
const dummyLinkTemplate = makeTemplateCloner("link_row_dummy");
parentForm.addEventListener("submit", function() {
updateLinksJSON();
});
const initialLinks = JSON.parse("{{ . }}");
for (const link of initialLinks) {
const l = linkTemplate();
l.nameInput.value = link.name;
l.urlInput.value = link.url;
if (link.primary) {
secondaryLinksTitle.insertAdjacentElement("beforebegin", l.root);
} else {
linksContainer.appendChild(l.root);
}
}
ensureLinksEmptyState();
function addLink(e) {
e.preventDefault();
linksContainer.appendChild(linkTemplate().root);
}
function deleteLink(e) {
e.preventDefault();
const l = e.target.closest(".link_row");
l.remove();
ensureLinksEmptyState();
}
function ensureLinksEmptyState() {
if (!linksContainer.querySelector(".link_row")) {
// Empty state is a single row
linksContainer.appendChild(linkTemplate().root);
}
}
function updateLinksJSON() {
const links = [];
let primary = true;
let els = linksContainer.children;
for (let i = 0; i < els.length; ++i) {
let el = els[i];
if (el.classList.contains("secondary_links")) {
primary = false;
continue;
}
if (el.classList.contains("link_row")) {
const name = el.querySelector(".link_name").value;
const url = el.querySelector(".link_url").value;
if (!url) {
continue;
}
links.push({
"name": name,
"url": url,
"primary": primary,
});
}
}
linksJSONInput.value = JSON.stringify(links);
}
let draggingLink = null;
let linkDragStartY = 0;
let linkDragStartMouseY = 0;
function startLinkDrag(e) {
e.preventDefault();
const l = e.target.closest(".link_row");
const top = l.offsetTop;
l.insertAdjacentElement("beforebegin", dummyLinkTemplate().root);
document.body.classList.add("grabbing");
l.style.position = "absolute";
l.style.top = `${top}px`;
l.classList.add("link_dragging");
l.classList.remove("drop_slot");
draggingLink = l;
linkDragStartY = top;
linkDragStartMouseY = e.pageY;
}
function doLinkDrag(e) {
if (!draggingLink) {
return;
}
const maxTop = linksContainer.offsetHeight - draggingLink.offsetHeight;
const delta = e.pageY - linkDragStartMouseY;
const top = Math.max(0, Math.min(maxTop, linkDragStartY + delta));
const middle = top + draggingLink.offsetHeight/2;
draggingLink.style.top = `${top}px`;
const slots = linksContainer.querySelectorAll(".drop_slot");
let closestSlot = null;
let slotDist = Number.MAX_VALUE;
for (let i = 0; i < slots.length; ++i) {
let slotMiddle = slots[i].offsetTop + slots[i].offsetHeight/2;
let dist = Math.abs(middle - slotMiddle);
if (dist < slotDist) {
closestSlot = slots[i];
slotDist = dist;
}
}
const dummy = linksContainer.querySelector(".link_row_dummy");
if (!closestSlot.classList.contains("link_row_dummy")) {
let replaceType = "afterend";
if (closestSlot.offsetTop < dummy.offsetTop) {
replaceType = "beforebegin";
}
closestSlot.insertAdjacentElement(replaceType, dummy);
}
}
function endLinkDrag(e) {
if (!draggingLink) {
return;
}
const dummy = linksContainer.querySelector(".link_row_dummy");
draggingLink.remove();
dummy.insertAdjacentElement("beforebegin", draggingLink);
dummy.remove();
draggingLink.style.position = null;
draggingLink.style.top = null;
draggingLink.classList.remove("link_dragging");
draggingLink.classList.add("drop_slot");
document.body.classList.remove("grabbing");
draggingLink = null;
}
window.addEventListener("mouseup", endLinkDrag);
window.addEventListener("mousemove", doLinkDrag);
</script>
</fieldset>

View File

@ -155,32 +155,7 @@
</fieldset>
{{ end }}
<fieldset>
<legend class="flex justify-between">
<span>Links</span>
<a href="#" class="normal" onclick="addLink(event)">+ Add Link</a>
</legend>
<div class="pa3 input-group">
<div id="links" class="flex flex-column g3 relative">
<div>Primary Links</div>
<div>Secondary Links</div>
</div>
<template id="link_row">
<div class="link_row w-100 flex flex-row items-center" data-tmpl="root">
<span class="link_handle svgicon pr3 pointer grab" onmousedown="startLinkDrag(event)">{{ svg "draggable" }}</span>
<input data-tmpl="nameInput" class="link_name mr3 w5" type="text" placeholder="Name" />
<input data-tmpl="urlInput" class="link_url flex-grow-1" type="url" placeholder="Link" />
<a class="delete_link svgicon link-normal pl3 f3" href="javascript:;" onclick="deleteLink(event)">{{ svg "delete" }}</a>
</div>
</template>
<template id="link_row_dummy">
<div class="link_row_dummy flex flex-row" data-tmpl="root">
<input class="o-0">
</div>
</template>
</div>
<input id="links_json" type="hidden" name="links">
</fieldset>
{{ template "link_editor.html" .ProjectSettings.LinksJSON }}
{{ if and .Editing .User.IsStaff }}
<fieldset>
@ -422,127 +397,5 @@
{{ .TextEditor.UploadUrl }}
);
///////////
// Links //
///////////
const linksContainer = document.querySelector("#links");
const linksJSONInput = document.querySelector("#links_json");
const linkTemplate = makeTemplateCloner("link_row");
const dummyLinkTemplate = makeTemplateCloner("link_row_dummy");
const initialLinks = JSON.parse("{{ .ProjectSettings.LinksJSON }}");
for (const link of initialLinks) {
const l = linkTemplate();
l.nameInput.value = link.name;
l.urlInput.value = link.url;
linksContainer.appendChild(l.root)
}
ensureLinksEmptyState();
projectForm.addEventListener("submit", () => updateLinksJSON());
function addLink(e) {
e.preventDefault();
linksContainer.appendChild(linkTemplate().root);
}
function deleteLink(e) {
e.preventDefault();
const l = e.target.closest(".link_row");
l.remove();
ensureLinksEmptyState();
}
function ensureLinksEmptyState() {
if (!linksContainer.querySelector(".link_row")) {
// Empty state is a single row
linksContainer.appendChild(linkTemplate().root);
}
}
function updateLinksJSON() {
const links = [];
for (const l of linksContainer.querySelectorAll(".link_row")) {
const value = l.querySelector(".link_name").value;
const url = l.querySelector(".link_url").value;
if (!url) {
continue;
}
links.push({
"name": name,
"url": url,
});
}
linksJSONInput.value = JSON.stringify(links);
}
let draggingLink = null;
let linkDragStartY = 0;
let linkDragStartMouseY = 0;
function startLinkDrag(e) {
e.preventDefault();
const l = e.target.closest(".link_row");
const top = l.offsetTop;
l.insertAdjacentElement("beforebegin", dummyLinkTemplate().root);
document.querySelector("body").classList.add("grabbing");
l.style.position = "absolute";
l.style.top = `${top}px`;
l.classList.add("link_dragging");
draggingLink = l;
linkDragStartY = top;
linkDragStartMouseY = e.pageY;
}
function doLinkDrag(e) {
if (!draggingLink) {
return;
}
const maxTop = linksContainer.offsetHeight - draggingLink.offsetHeight;
const delta = e.pageY - linkDragStartMouseY;
const top = Math.max(0, Math.min(maxTop, linkDragStartY + delta));
const middle = top + draggingLink.offsetHeight/2;
draggingLink.style.top = `${top}px`;
const numLinks = linksContainer.querySelectorAll(".link_row").length;
const itemHeight = linksContainer.offsetHeight / numLinks;
const index = Math.floor(middle / itemHeight);
const links = linksContainer.querySelectorAll(".link_row:not(.link_dragging)");
const dummy = linksContainer.querySelector(".link_row_dummy");
dummy.remove();
linksContainer.insertBefore(dummy, links[index]);
}
function endLinkDrag(e) {
if (!draggingLink) {
return;
}
const dummy = linksContainer.querySelector(".link_row_dummy");
draggingLink.remove();
linksContainer.insertBefore(draggingLink, dummy);
dummy.remove();
draggingLink.style.position = null;
draggingLink.style.top = null;
draggingLink.classList.remove("link_dragging");
document.querySelector("body").classList.remove("grabbing");
draggingLink = null;
}
window.addEventListener("mouseup", endLinkDrag);
window.addEventListener("mousemove", doLinkDrag);
</script>
{{ end }}

View File

@ -2,6 +2,7 @@
{{ define "extrahead" }}
<script src="{{ static "js/image_selector.js" }}"></script>
<script src="{{ static "js/templates.js" }}"></script>
{{ end }}
{{ define "content" }}
@ -63,9 +64,7 @@
</script>
</div>
</fieldset>
<fieldset>
<legend>Links</legend>
</fieldset>
{{ template "link_editor.html" .LinksJSON }}
<div class="input-group">
<label for="shortbio">Short bio</label>
<textarea class="w-100" maxlength="140" data-max-chars="140" name="shortbio" id="shortbio">
@ -306,7 +305,6 @@
<div class="pt-input-ns">Links:</div>
<div>
<textarea class="links" name="links" id="links" maxlength="2048" data-max-chars="2048">
{{- .LinksText -}}
</textarea>
<div class="c--dim f7">
<div>Relevant links to put on your profile.</div>

View File

@ -225,6 +225,7 @@ type Link struct {
ServiceName string `json:"serviceName"`
Username string `json:"text"`
Icon string `json:"icon"`
Primary bool `json:"primary"`
}
type Podcast struct {

View File

@ -8,8 +8,9 @@ import (
)
type ParsedLink struct {
Name string `json:"name"`
Url string `json:"url"`
Name string `json:"name"`
Url string `json:"url"`
Primary bool `json:"primary"`
}
func ParseLinks(text string) []ParsedLink {

View File

@ -935,12 +935,13 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
for i, link := range payload.Links {
_, err = tx.Exec(ctx,
`
INSERT INTO link (name, url, ordering, project_id)
VALUES ($1, $2, $3, $4)
INSERT INTO link (name, url, ordering, primary_link, project_id)
VALUES ($1, $2, $3, $4, $5)
`,
link.Name,
link.Url,
i,
link.Primary,
payload.ProjectID,
)
if err != nil {

View File

@ -1,6 +1,7 @@
package website
import (
"encoding/json"
"errors"
"net/http"
"strconv"
@ -193,7 +194,7 @@ func UserSettings(c *RequestContext) ResponseData {
Avatar *templates.Asset
Email string // these fields are handled specially on templates.User
ShowEmail bool
LinksText string
LinksJSON string
HasPassword bool
SubmitUrl string
@ -219,7 +220,7 @@ func UserSettings(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
}
linksText := LinksToText(links)
linksJSON := string(utils.Must1(json.Marshal(templates.LinksToTemplate(links))))
var tduser *templates.DiscordUser
var numUnsavedMessages int
@ -269,7 +270,7 @@ func UserSettings(c *RequestContext) ResponseData {
Avatar: templates.AssetToTemplate(c.CurrentUser.AvatarAsset),
Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
LinksJSON: linksJSON,
HasPassword: c.CurrentUser.Password != "",
SubmitUrl: hmnurl.BuildUserSettings(""),
@ -378,12 +379,13 @@ func UserSettingsSave(c *RequestContext) ResponseData {
for i, link := range links {
_, err := tx.Exec(c,
`
INSERT INTO link (name, url, ordering, user_id)
VALUES ($1, $2, $3, $4)
INSERT INTO link (name, url, ordering, primary_link, user_id)
VALUES ($1, $2, $3, $4, $5)
`,
link.Name,
link.Url,
i,
link.Primary,
c.CurrentUser.ID,
)
if err != nil {