Link editor
This commit is contained in:
parent
ba86da3374
commit
908c8b02f8
|
@ -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
|
||||
}
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -335,6 +335,7 @@ func LinkToTemplate(link *models.Link) Link {
|
|||
ServiceName: service.Name,
|
||||
Icon: service.IconName,
|
||||
Username: username,
|
||||
Primary: link.Primary,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue