Compare commits

...

4 Commits

Author SHA1 Message Date
Asaf Gartner a9b0606b79 Use new snippet-project association for jam index showcase 2022-08-06 00:48:56 +03:00
Asaf Gartner 89b1e48e69 Code review 2022-08-06 00:42:08 +03:00
Asaf Gartner 87a146dfa8 Fetch jam snippets by project id 2022-08-06 00:42:08 +03:00
Asaf Gartner efc7d76cb7 Snippet creation and editing 2022-08-06 00:41:37 +03:00
34 changed files with 1390 additions and 175 deletions

1
go.mod
View File

@ -62,6 +62,7 @@ require (
golang.org/x/text v0.3.6 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
mvdan.cc/xurls/v2 v2.4.0 // indirect
)
replace (

4
go.sum
View File

@ -272,6 +272,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -290,6 +291,7 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
@ -526,4 +528,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc=
mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

View File

@ -8,7 +8,7 @@ const TimelineMediaTypes = {
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
const modalTemplate = makeTemplateCloner("timeline_modal");
const tagTemplate = makeTemplateCloner("timeline_item_tag");
const projectLinkTemplate = makeTemplateCloner("project_link");
function showcaseTimestamp(rawDate) {
const date = new Date(rawDate*1000);
@ -97,14 +97,16 @@ function makeShowcaseItem(timelineItem) {
modalEl.date.textContent = timestamp;
modalEl.date.setAttribute("href", timelineItem.snippet_url);
if (timelineItem.tags.length === 0) {
modalEl.tags.remove();
if (timelineItem.projects.length === 0) {
modalEl.projects.remove();
} else {
for (const tag of timelineItem.tags) {
const tagItem = tagTemplate();
tagItem.tag.innerText = tag.text;
for (const proj of timelineItem.projects) {
const projectLink = projectLinkTemplate();
projectLink.root.href = proj.url;
projectLink.logo.src = proj.logo;
projectLink.name.textContent = proj.name;
modalEl.tags.appendChild(tagItem.root);
modalEl.projects.appendChild(projectLink.root);
}
}

359
public/js/snippetedit.js Normal file
View File

@ -0,0 +1,359 @@
const snippetEditTemplate = makeTemplateCloner("snippet-edit");
const snippetEditProjectTemplate = makeTemplateCloner("snippet-edit-project");
function readableByteSize(numBytes) {
const scales = [
" bytes",
"kb",
"mb",
"gb"
];
let scale = 0;
while (numBytes > 1024 && scale < scales.length-1) {
numBytes /= 1024;
scale++;
}
return new Intl.NumberFormat([], { maximumFractionDigits: (scale > 0 ? 2 : 0) }).format(numBytes) + scales[scale];
}
function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmentElement, projectIds, snippetId, originalSnippetEl) {
let snippetEdit = snippetEditTemplate();
let projectSelector = null;
let originalAttachment = null;
let originalText = text;
let attachmentChanged = false;
let hasAttachment = false;
snippetEdit.redirect.value = location.href;
snippetEdit.avatarImg.src = ownerAvatar;
snippetEdit.avatarLink.href = ownerUrl;
snippetEdit.username.textContent = ownerName;
snippetEdit.username.href = ownerUrl;
snippetEdit.date.textContent = new Intl.DateTimeFormat([], {month: "2-digit", day: "2-digit", year: "numeric"}).format(date);
snippetEdit.text.value = text;
if (attachmentElement) {
originalAttachment = attachmentElement.cloneNode(true);
clearAttachment(true);
}
if (snippetId !== undefined && snippetId !== null) {
snippetEdit.snippetId.value = snippetId;
} else {
snippetEdit.deleteButton.remove();
}
for (let i = 0; i < projectIds.length; ++i) {
let proj = null;
for (let j = 0; j < availableProjects.length; ++j) {
if (projectIds[i] == availableProjects[j].id) {
proj = availableProjects[j];
break;
}
}
if (proj) {
addProject(proj);
}
}
updateProjectSelector();
function addProject(proj) {
let projEl = snippetEditProjectTemplate();
projEl.projectId.value = proj.id;
projEl.projectLogo.src = proj.logo;
projEl.projectName.textContent = proj.name;
projEl.removeButton.addEventListener("click", function(ev) {
projEl.root.remove();
updateProjectSelector();
});
snippetEdit.projectList.appendChild(projEl.root);
}
function updateProjectSelector() {
if (projectSelector) {
projectSelector.remove();
}
let remainingProjects = [];
let projInputs = snippetEdit.projectList.querySelectorAll("input[name=project_id]");
let assignedIds = [];
for (let i = 0; i < projInputs.length; ++i) {
let id = parseInt(projInputs[i].value, 10);
if (!isNaN(id)) {
assignedIds.push(id);
}
}
for (let i = 0; i < availableProjects.length; ++i) {
let found = false;
for (let j = 0; j < assignedIds.length; ++j) {
if (assignedIds[j] == availableProjects[i].id) {
found = true;
break;
}
}
if (!found) {
remainingProjects.push(availableProjects[i]);
}
}
if (remainingProjects.length > 0) {
projectSelector = document.createElement("SELECT");
let option = document.createElement("OPTION");
option.textContent = "Add to project...";
option.selected = true;
projectSelector.appendChild(option);
for (let i = 0; i < remainingProjects.length; ++i) {
option = document.createElement("OPTION");
option.value = remainingProjects[i].id;
option.selected = false;
option.textContent = remainingProjects[i].name;
projectSelector.appendChild(option);
}
projectSelector.addEventListener("change", function(ev) {
if (projectSelector.selectedOptions.length > 0) {
let selected = projectSelector.selectedOptions[0];
if (selected.value != "") {
let id = parseInt(selected.value, 10);
if (!isNaN(id)) {
for (let i = 0; i < availableProjects.length; ++i) {
if (availableProjects[i].id == id) {
addProject(availableProjects[i]);
break;
}
}
}
updateProjectSelector();
}
}
});
snippetEdit.projectList.appendChild(projectSelector);
}
}
function setFile(file) {
let dt = new DataTransfer();
dt.items.add(file);
snippetEdit.file.files = dt.files;
attachmentChanged = true;
snippetEdit.removeAttachment.value = "false";
hasAttachment = true;
let el = null;
if (file.type.startsWith("image/")) {
el = document.createElement("img");
el.src = URL.createObjectURL(file);
} else if (file.type.startsWith("video/")) {
el = document.createElement("video");
el.src = URL.createObjectURL(file);
el.controls = true;
} else if (file.type.startsWith("audio/")) {
el = document.createElement("audio");
el.src = URL.createObjectURL(file);
} else {
el = document.createElement("div");
el.classList.add("project-card", "br2", "pv1", "ph2");
let anchor = document.createElement("a");
anchor.href = URL.createObjectURL(file);
anchor.setAttribute("target", "_blank");
anchor.textContent = file.name + " (" + readableByteSize(file.size) + ")";
el.appendChild(anchor);
}
setPreview(el);
validate();
}
function clearAttachment(restoreOriginal) {
snippetEdit.file.value = "";
let el = null;
attachmentChanged = false;
hasAttachment = false;
snippetEdit.removeAttachment.value = "false";
if (originalAttachment) {
if (restoreOriginal) {
hasAttachment = true;
el = originalAttachment;
} else {
attachmentChanged = true;
snippetEdit.removeAttachment.value = "true";
}
}
setPreview(el);
validate();
}
function setPreview(el) {
if (el) {
snippetEdit.uploadBox.style.display = "none";
snippetEdit.previewBox.style.display = "block";
snippetEdit.uploadResetLink.style.display = "none";
snippetEdit.previewContent = emptyElement(snippetEdit.previewContent);
snippetEdit.previewContent.appendChild(el);
snippetEdit.resetLink.style.display = (!originalAttachment || el == originalAttachment) ? "none" : "inline-block";
} else {
snippetEdit.uploadBox.style.display = "flex";
snippetEdit.previewBox.style.display = "none";
if (originalAttachment) {
snippetEdit.uploadResetLink.style.display = "block";
}
}
}
function validate() {
let sizeGood = true;
if (snippetEdit.file.files.length > 0 && snippetEdit.file.files[0].size > maxFilesize) {
// NOTE(asaf): Writing this out in bytes to make the limit exactly clear to the user.
let readableSize = new Intl.NumberFormat([], { useGrouping: "always" }).format(maxFilesize);
snippetEdit.errors.textContent = "File is too big! Max filesize is " + readableSize + " bytes";
sizeGood = false;
} else {
snippetEdit.errors.textContent = "";
}
let hasText = snippetEdit.text.value.trim().length > 0;
if ((hasText || hasAttachment) && sizeGood) {
snippetEdit.saveButton.disabled = false;
} else {
snippetEdit.saveButton.disabled = true;
}
}
snippetEdit.uploadLink.addEventListener("click", function() {
snippetEdit.file.click();
});
snippetEdit.removeLink.addEventListener("click", function() {
clearAttachment(false);
});
snippetEdit.replaceLink.addEventListener("click", function() {
snippetEdit.file.click();
});
snippetEdit.resetLink.addEventListener("click", function() {
clearAttachment(true);
});
snippetEdit.uploadResetLink.addEventListener("click", function() {
clearAttachment(true);
});
snippetEdit.file.addEventListener("change", function() {
if (snippetEdit.file.files.length > 0) {
setFile(snippetEdit.file.files[0]);
}
});
snippetEdit.root.addEventListener("dragover", function(ev) {
let effect = "none";
for (let i = 0; i < ev.dataTransfer.items.length; ++i) {
if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") {
effect = "copy";
break;
}
}
ev.dataTransfer.dropEffect = effect;
ev.preventDefault();
});
let enterCounter = 0;
snippetEdit.root.addEventListener("dragenter", function(ev) {
enterCounter++;
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length > 0) {
snippetEdit.root.classList.add("drop");
}
});
snippetEdit.root.addEventListener("dragleave", function(ev) {
enterCounter--;
if (enterCounter == 0) {
snippetEdit.root.classList.remove("drop");
}
});
snippetEdit.root.addEventListener("drop", function(ev) {
enterCounter = 0;
snippetEdit.root.classList.remove("drop");
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length > 0) {
setFile(ev.dataTransfer.files[0]);
}
ev.preventDefault();
});
snippetEdit.text.addEventListener("paste", function(ev) {
const files = ev.clipboardData?.files ?? [];
if (files.length > 0) {
setFile(files[0]);
}
});
snippetEdit.text.addEventListener("input", function(ev) {
validate();
});
snippetEdit.saveButton.addEventListener("click", function(ev) {
let projectsChanged = false;
let projInputs = snippetEdit.projectList.querySelectorAll("input[name=project_id]");
let assignedIds = [];
for (let i = 0; i < projInputs.length; ++i) {
let id = parseInt(projInputs[i].value, 10);
if (!isNaN(id)) {
assignedIds.push(id);
}
}
if (projectIds.length != assignedIds.length) {
projectsChanged = true;
} else {
for (let i = 0; i < projectIds.length; ++i) {
let found = false;
for (let j = 0; j < assignedIds.length; ++j) {
if (projectIds[i] == assignedIds[j]) {
found = true;
}
}
if (!found) {
projectsChanged = true;
break;
}
}
}
if (originalSnippetEl && (!attachmentChanged && originalText == snippetEdit.text.value.trim() && !projectsChanged)) {
// NOTE(asaf): We're in edit mode and nothing changed, so no need to submit to the server.
ev.preventDefault();
snippetEdit.root.parentElement.insertBefore(originalSnippetEl, snippetEdit.root);
snippetEdit.root.remove();
}
});
snippetEdit.deleteButton.addEventListener("click", function(ev) {
snippetEdit.file.value = "";
});
validate();
return snippetEdit;
}
function editTimelineSnippet(timelineItemEl) {
let ownerName = timelineItemEl.querySelector(".user")?.textContent;
let ownerUrl = timelineItemEl.querySelector(".user")?.href;
let ownerAvatar = timelineItemEl.querySelector(".avatar-icon")?.src;
let creationDate = new Date(timelineItemEl.querySelector("time").dateTime);
let rawDesc = timelineItemEl.querySelector(".rawdesc").textContent;
let attachment = timelineItemEl.querySelector(".timeline-content-box")?.children?.[0];
let projectIds = [];
let projectEls = timelineItemEl.querySelectorAll(".projects > a");
for (let i = 0; i < projectEls.length; ++i) {
let projid = projectEls[i].getAttribute("data-projid");
if (projid) {
projectIds.push(projid);
}
}
let snippetEdit = makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, creationDate, rawDesc, attachment, projectIds, timelineItemEl.getAttribute("data-id"), timelineItemEl);
timelineItemEl.parentElement.insertBefore(snippetEdit.root, timelineItemEl);
timelineItemEl.remove();
}

View File

@ -7461,6 +7461,12 @@ article code {
.minh-6 {
min-height: 32rem; }
.h1-5 {
height: 1.5rem; }
.pre-line {
white-space: pre-line; }
.fira {
font-family: "Fira Sans", sans-serif; }

View File

@ -466,5 +466,48 @@ func init() {
moveThreadsToSubforumCommand.MarkFlagRequired("subforum_slug")
adminCommand.AddCommand(moveThreadsToSubforumCommand)
fixupSnippetAssociation := &cobra.Command{
Use: "fixupsnippets",
Short: "Associates tagged snippets with the right projects",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
conn := db.NewConn()
defer conn.Close(ctx)
type snippetProject struct {
SnippetID int `db:"snippet_tag.snippet_id"`
ProjectID int `db:"project.id"`
}
res, err := db.Query[snippetProject](ctx, conn,
`
SELECT $columns
FROM snippet_tag
JOIN project ON project.tag = snippet_tag.tag_id
`,
)
if err != nil {
panic(err)
}
for _, sp := range res {
_, err = conn.Exec(ctx,
`
INSERT INTO snippet_project (snippet_id, project_id, kind)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
`,
sp.SnippetID,
sp.ProjectID,
models.SnippetProjectKindDiscord,
)
if err != nil {
panic(err)
}
}
fmt.Printf("Done!\n")
},
}
adminCommand.AddCommand(fixupSnippetAssociation)
addProjectCommands(adminCommand)
}

View File

@ -783,9 +783,6 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
// Match only tags for projects in which the current user is a collaborator.
messageTags := getDiscordTags(existingSnippet.Description)
var desiredTags []int
var allTags []int
// Fetch projects so we know what tags the user can apply to their snippet.
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
OwnerIDs: []int{interned.HMNUser.ID},
@ -794,61 +791,40 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
return oops.New(err, "failed to look up user projects")
}
projectIDs := make([]int, len(projects))
for i, p := range projects {
projectIDs[i] = p.Project.ID
}
userTags, err := db.Query[models.Tag](ctx, tx,
`
SELECT $columns{tag}
FROM
tag
JOIN project ON project.tag = tag.id
WHERE
project.id = ANY ($1)
`,
projectIDs,
)
if err != nil {
return oops.New(err, "failed to fetch tags for user projects")
}
for _, tag := range userTags {
allTags = append(allTags, tag.ID)
for _, messageTag := range messageTags {
if strings.EqualFold(tag.Text, messageTag) {
desiredTags = append(desiredTags, tag.ID)
}
}
}
_, err = tx.Exec(ctx,
`
DELETE FROM snippet_tag
DELETE FROM snippet_project
WHERE
snippet_id = $1
AND tag_id = ANY ($2)
AND kind = $2
`,
existingSnippet.ID,
allTags,
models.SnippetProjectKindDiscord,
)
if err != nil {
return oops.New(err, "failed to clear tags from snippet")
return oops.New(err, "failed to clear project association for snippet")
}
for _, tagID := range desiredTags {
_, err = tx.Exec(ctx,
`
INSERT INTO snippet_tag (snippet_id, tag_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`,
existingSnippet.ID,
tagID,
)
if err != nil {
return oops.New(err, "failed to associate snippet with tag")
for _, p := range projects {
if p.Tag != nil {
for _, messageTag := range messageTags {
if strings.EqualFold(p.Tag.Text, messageTag) {
_, err = tx.Exec(ctx,
`
INSERT INTO snippet_project (project_id, snippet_id, kind)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`,
p.Project.ID,
existingSnippet.ID,
models.SnippetProjectKindDiscord,
)
if err != nil {
return oops.New(err, "failed to associate snippet with project")
}
break
}
}
}
}
}
@ -876,6 +852,7 @@ func getDiscordTags(content string) []string {
// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
// do we actually want to reuse those, or should we keep them separate?
// TODO(asaf): Centralize this
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {

194
src/embed/embed.go Normal file
View File

@ -0,0 +1,194 @@
package embed
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/utils"
)
var DownloadTooBigError = errors.New("download too big")
var NoEmbedFound = errors.New("no embed found")
type Embeddable struct {
Url string
File *Embed
}
type Embed struct {
Data []byte
ContentType string
Filename string
}
var EmbeddableUrlRegex = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
func IsUrlEmbeddable(u string) bool {
return EmbeddableUrlRegex.MatchString(u)
}
func GetEmbeddableFromUrls(ctx context.Context, urls []string, maxSize int, httpTimeout time.Duration, httpMaxAttempts int) (*Embeddable, error) {
embedError := NoEmbedFound
client := &http.Client{
Timeout: httpTimeout,
}
for _, urlStr := range urls {
u, err := url.Parse(urlStr)
if err != nil {
continue
}
if u.Scheme == "" {
u.Scheme = "https"
urlStr = u.String()
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
continue
}
if IsUrlEmbeddable(urlStr) {
result := Embeddable{
Url: urlStr,
}
return &result, nil
}
if httpMaxAttempts > 0 {
httpMaxAttempts -= 1
embed, err := FetchEmbed(ctx, urlStr, client, maxSize)
if err != nil {
embedError = err
continue
}
result := Embeddable{
File: embed,
}
return &result, nil
}
}
return nil, embedError
}
// If the url points to a file, downloads and returns the file.
// If the url points to an html page, parses opengraph and tries to fetch an image/video/audio file according to that.
// maxSize only limits the actual filesize. In the case of html we always fetch up to 100kb even if maxSize is smaller.
func FetchEmbed(ctx context.Context, urlStr string, httpClient *http.Client, maxSize int) (*Embed, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, NoEmbedFound
}
contentType := res.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "text/html") || strings.HasPrefix(contentType, "application/html") {
var buffer bytes.Buffer
_, err := io.CopyN(&buffer, res.Body, 100*1024) // NOTE(asaf): If the opengraph stuff isn't in the first 100kb, we don't care.
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
partialHtml := buffer.Bytes()
urlStr = ExtractEmbedFromOpenGraph(partialHtml)
if urlStr == "" {
return nil, NoEmbedFound
}
req, err = http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return nil, err
}
res, err = httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, NoEmbedFound
}
contentType = res.Header.Get("Content-Type")
}
var buffer bytes.Buffer
n, err := io.CopyN(&buffer, res.Body, int64(maxSize+1))
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
filename := ""
u, err := url.Parse(urlStr)
if err == nil {
lastSlash := utils.IntMax(strings.LastIndex(u.Path, "/"), 0)
filename = u.Path[lastSlash:]
}
result := Embed{
Data: buffer.Bytes(),
ContentType: contentType,
Filename: filename,
}
if n == int64(maxSize+1) {
err = DownloadTooBigError
} else {
err = nil
}
return &result, err
}
var metaRegex = regexp.MustCompile(`<meta\s+([^>]+)/?>`)
var metaAttrRegex = regexp.MustCompile(`(?P<key>\w+)="(?P<value>[^"]+)"`)
var OGKeys = []string{
"og:audio",
"og:video",
"og:image",
"og:audio:url",
"og:image:url",
"og:video:url",
"og:audio:secure_url",
"og:image:secure_url",
"og:video:secure_url",
"twitter:image",
}
// Tries to find an opengraph image/video/audio url in the provided html
// Since we only need to look at meta tags in the head, we don't need the full html document.
func ExtractEmbedFromOpenGraph(partialHtml []byte) string {
keyIdx := metaAttrRegex.SubexpIndex("key")
valueIdx := metaAttrRegex.SubexpIndex("value")
html := string(partialHtml)
metaTags := metaRegex.FindAllStringSubmatch(html, -1)
for _, m := range metaTags {
content := ""
relevantProp := false
attrs := metaAttrRegex.FindAllStringSubmatch(m[1], -1)
for _, attr := range attrs {
key := attr[keyIdx]
value := attr[valueIdx]
if key == "name" || key == "property" {
for _, ogKey := range OGKeys {
if value == ogKey {
relevantProp = true
break
}
}
} else if key == "content" {
content = value
}
}
if content != "" && relevantProp {
return content
}
}
return ""
}

View File

@ -12,6 +12,7 @@ import (
type SnippetQuery struct {
IDs []int
OwnerIDs []int
ProjectIDs []int
Tags []int
DiscordMessageIDs []string
@ -24,6 +25,7 @@ type SnippetAndStuff struct {
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
Tags []*models.Tag
Projects []*ProjectAndStuff
}
func FetchSnippets(
@ -42,6 +44,7 @@ func FetchSnippets(
}
defer tx.Rollback(ctx)
var tagSnippetIDs []int
if len(q.Tags) > 0 {
// Get snippet IDs with this tag, then use that in the main query
snippetIDs, err := db.QueryScalar[int](ctx, tx,
@ -64,7 +67,32 @@ func FetchSnippets(
return nil, nil
}
q.IDs = snippetIDs
tagSnippetIDs = snippetIDs
}
var projectSnippetIDs []int
if len(q.ProjectIDs) > 0 {
// Get snippet IDs for these projects, then use that in the main query
snippetIDs, err := db.QueryScalar[int](ctx, tx,
`
SELECT DISTINCT snippet_id
FROM
snippet_project
WHERE
project_id = ANY ($1)
`,
q.ProjectIDs,
)
if err != nil {
return nil, oops.New(err, "failed to get snippet IDs for tag")
}
// special early-out: no snippets found for these projects at all
if len(snippetIDs) == 0 {
return nil, nil
}
projectSnippetIDs = snippetIDs
}
var qb db.QueryBuilder
@ -74,7 +102,7 @@ func FetchSnippets(
FROM
snippet
LEFT JOIN hmn_user AS owner ON snippet.owner_id = owner.id
LEFT JOIN asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id
LEFT JOIN asset AS avatar ON avatar.id = owner.avatar_asset_id
LEFT JOIN asset ON snippet.asset_id = asset.id
LEFT JOIN discord_message ON snippet.discord_message_id = discord_message.id
WHERE
@ -84,6 +112,12 @@ func FetchSnippets(
if len(q.IDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
}
if len(tagSnippetIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, tagSnippetIDs)
}
if len(projectSnippetIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, projectSnippetIDs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
}
@ -115,6 +149,7 @@ func FetchSnippets(
type resultRow struct {
Snippet models.Snippet `db:"snippet"`
Owner *models.User `db:"owner"`
AvatarAsset *models.Asset `db:"avatar"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
@ -127,6 +162,9 @@ func FetchSnippets(
result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
snippetIDs := make([]int, len(results))
for i, row := range results {
if results[i].Owner != nil {
results[i].Owner.AvatarAsset = results[i].AvatarAsset
}
result[i] = SnippetAndStuff{
Snippet: row.Snippet,
Owner: row.Owner,
@ -167,6 +205,42 @@ func FetchSnippets(
item.Tags = append(item.Tags, snippetTag.Tag)
}
// Fetch projects
type snippetProjectRow struct {
SnippetID int `db:"snippet_id"`
ProjectID int `db:"project_id"`
}
snippetProjects, err := db.Query[snippetProjectRow](ctx, tx,
`
SELECT $columns
FROM snippet_project
WHERE snippet_id = ANY($1)
`,
snippetIDs,
)
if err != nil {
return nil, oops.New(err, "failed to fetch project ids for snippets")
}
var projectIds []int
for _, sp := range snippetProjects {
projectIds = append(projectIds, sp.ProjectID)
}
projects, err := FetchProjects(ctx, tx, currentUser, ProjectsQuery{ProjectIDs: projectIds})
if err != nil {
return nil, oops.New(err, "failed to fetch projects for snippets")
}
projectMap := make(map[int]*ProjectAndStuff)
for i := range projects {
projectMap[projects[i].Project.ID] = &projects[i]
}
for _, sp := range snippetProjects {
snip, hasResult := resultBySnippetId[sp.SnippetID]
proj, hasProj := projectMap[sp.ProjectID]
if hasResult && hasProj {
snip.Projects = append(snip.Projects, proj)
}
}
err = tx.Commit(ctx)
if err != nil {
return nil, oops.New(err, "failed to commit transaction")

View File

@ -120,6 +120,10 @@ func TestSnippet(t *testing.T) {
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
}
func TestSnippetSubmit(t *testing.T) {
AssertRegexMatch(t, BuildSnippetSubmit(), RegexSnippetSubmit, nil)
}
func TestFeed(t *testing.T) {
AssertRegexMatch(t, BuildFeed(), RegexFeed, nil)
assert.Equal(t, BuildFeed(), BuildFeedWithPage(1))

View File

@ -262,6 +262,13 @@ func BuildSnippet(snippetId int) string {
return Url("/snippet/"+strconv.Itoa(snippetId), nil)
}
var RegexSnippetSubmit = regexp.MustCompile(`^/snippet$`)
func BuildSnippetSubmit() string {
defer CatchPanic()
return Url("/snippet", nil)
}
/*
* Feed
*/

View File

@ -0,0 +1,50 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(SnippetProjectAssociation{})
}
type SnippetProjectAssociation struct{}
func (m SnippetProjectAssociation) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2022, 6, 26, 11, 57, 3, 0, time.UTC))
}
func (m SnippetProjectAssociation) Name() string {
return "SnippetProjectAssociation"
}
func (m SnippetProjectAssociation) Description() string {
return "Table for associating a snippet with projects"
}
func (m SnippetProjectAssociation) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
CREATE TABLE snippet_project (
snippet_id INTEGER NOT NULL REFERENCES snippet (id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES project (id) ON DELETE CASCADE,
kind INTEGER NOT NULL,
UNIQUE (snippet_id, project_id)
);
`,
)
return err
}
func (m SnippetProjectAssociation) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
DROP TABLE snippet_project;
`,
)
return err
}

View File

@ -6,6 +6,13 @@ import (
"github.com/google/uuid"
)
type SnippetProjectAssociationKind int
const (
SnippetProjectKindDiscord SnippetProjectAssociationKind = iota + 1
SnippetProjectKindWebsite
)
type Snippet struct {
ID int `db:"id"`
OwnerID int `db:"owner_id"`

View File

@ -333,6 +333,14 @@ article code {
min-height: $height-6;
}
.h1-5 {
height: 1.5rem;
}
.pre-line {
white-space: pre-line;
}
.fira {
font-family: "Fira Sans", sans-serif;
}

View File

@ -78,6 +78,7 @@ func ProjectToTemplate(
url string,
) Project {
return Project{
ID: p.ID,
Name: p.Name,
Subdomain: p.Subdomain(),
Color1: p.Color1,
@ -297,8 +298,6 @@ func LinkToTemplate(link *models.Link) Link {
return tlink
}
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
func TimelineItemsToJSON(items []TimelineItem) string {
// NOTE(asaf): As of 2021-06-22: This only serializes the data necessary for snippet showcase.
builder := strings.Builder{}
@ -378,22 +377,26 @@ func TimelineItemsToJSON(items []TimelineItem) string {
builder.WriteString(item.DiscordMessageUrl)
builder.WriteString(`",`)
builder.WriteString(`"tags":[`)
for i, tag := range item.Tags {
builder.WriteString(`{`)
builder.WriteString(`"projects":[`)
for j, proj := range item.Projects {
if j > 0 {
builder.WriteRune(',')
}
builder.WriteRune('{')
builder.WriteString(`"text":"`)
builder.WriteString(tag.Text)
builder.WriteString(`"name":"`)
builder.WriteString(proj.Name)
builder.WriteString(`",`)
builder.WriteString(`"logo":"`)
builder.WriteString(proj.Logo)
builder.WriteString(`",`)
builder.WriteString(`"url":"`)
builder.WriteString(tag.Url)
builder.WriteString(proj.Url)
builder.WriteString(`"`)
builder.WriteString(`}`)
if i < len(item.Tags)-1 {
builder.WriteString(`,`)
}
builder.WriteRune('}')
}
builder.WriteString(`]`)
@ -403,6 +406,33 @@ func TimelineItemsToJSON(items []TimelineItem) string {
return builder.String()
}
func SnippetEditProjectsToJSON(projects []Project) string {
builder := strings.Builder{}
builder.WriteRune('[')
for i, proj := range projects {
if i > 0 {
builder.WriteRune(',')
}
builder.WriteRune('{')
builder.WriteString(`"id":`)
builder.WriteString(strconv.FormatInt(int64(proj.ID), 10))
builder.WriteRune(',')
builder.WriteString(`"name":"`)
builder.WriteString(proj.Name)
builder.WriteString(`",`)
builder.WriteString(`"logo":"`)
builder.WriteString(proj.Logo)
builder.WriteRune('"')
builder.WriteRune('}')
}
builder.WriteRune(']')
return builder.String()
}
func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast {
imageUrl := ""
if imageFilename != "" {

View File

@ -55,7 +55,7 @@
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
<div>
{{ .Description }}
{{ cleancontrolchars .Description }}
</div>
{{ range .EmbedMedia }}
<div>

View File

@ -27,10 +27,10 @@
<a class="user" data-tmpl="userLink"></a>
<a data-tmpl="date" class="datetime tr" style="flex: 1 1 auto;"></a>
</div>
<div class="pre overflow-auto" data-tmpl="description">
<div class="pre-line overflow-auto" data-tmpl="description">
Unknown description
</div>
<div data-tmpl="tags" class="pt2 flex"></div>
<div data-tmpl="projects" class="pt2 flex g2"></div>
<div class="i f7 pt2">
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
</div>
@ -40,6 +40,9 @@
</div>
</template>
<template id="timeline_item_tag">
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
<template id="project_link">
<a data-tmpl="root" class="flex flex-row items-center bg-theme-dimmer ph2 pv1 br2">
<img data-tmpl="logo" class="db mr1 br1 h1-5" />
<div data-tmpl="name"></div>
</a>
</template>

View File

@ -0,0 +1,53 @@
<template id="snippet-edit">
<form data-tmpl="root" class="timeline-item pa3 mb2 br3" method="POST" action="{{ .SnippetEdit.SubmitUrl }}" enctype="multipart/form-data">
{{ csrftoken .Session }}
<input data-tmpl="redirect" type="hidden" name="redirect" />
<input data-tmpl="snippetId" type="hidden" name="snippet_id" />
<input data-tmpl="removeAttachment" type="hidden" name="remove_attachment" value="false" />
<input data-tmpl="file" type="file" name="file" class="dn" />
<div class="flex items-center">
<a data-tmpl="avatarLink" class="flex-shrink-0"><img data-tmpl="avatarImg" class="avatar-icon lite mr2" /></a>
<a data-tmpl="username" class="flex-shrink-0"></a>
<div class="spacer flex-grow-1"></div>
<span data-tmpl="date" class="flex-shrink-0">Date</span>
</div>
<textarea data-tmpl="text" placeholder="Description and/or links" class="w-100 h4 mt3" name="text"></textarea>
<div class="mv3">
<div data-tmpl="uploadBox" class="placeholder flex flex-column items-center">
<img src="" class="db w4 h4"/>
<a data-tmpl="uploadResetLink" class="mt3 dn" href="javascript:;">Restore</a>
<a data-tmpl="uploadLink" class="mt3" href="javascript:;">Upload image, video, or other file</a>
</div>
<div data-tmpl="previewBox" class="preview dn">
<div class="actions">
<a data-tmpl="removeLink" href="javascript:;">Remove</a>
<a data-tmpl="resetLink" href="javascript:;">Restore</a>
<a data-tmpl="replaceLink" href="javascript:;">Replace</a>
</div>
<div data-tmpl="previewContent">
</div>
</div>
</div>
<div data-tmpl="projectList" class="flex flex-wrap g2"></div>
<div class="flex items-center">
<div data-tmpl="errors"></div>
<div class="flex-grow-1"></div>
<input data-tmpl="deleteButton" class="flex-grow-0 flex-shrink-0 mr3" type="submit" name="action" value="Delete" />
<input data-tmpl="saveButton" class="flex-grow-0 flex-shrink-0" type="submit" name="action" value="Save" />
</div>
</form>
</template>
<template id="snippet-edit-project">
<div data-tmpl="root" class="flex flex-row items-center bg-theme-dimmer ph2 pv1 br2">
<input data-tmpl="projectId" type="hidden" name="project_id" />
<img data-tmpl="projectLogo" class="db mr1 br1 h1-5"/>
<div data-tmpl="projectName"></div>
<a data-tmpl="removeButton" class="ml1" href="javascript:;">&#10006;</a>
</div>
</template>
<script>
const maxFilesize = {{ .SnippetEdit.AssetMaxSize }};
const availableProjects = JSON.parse("{{ .SnippetEdit.AvailableProjectsJSON }}");
</script>
<script src="{{ static "js/snippetedit.js" }}"></script>

View File

@ -1,4 +1,4 @@
<div class="timeline-item flex flex-column pa3 mb2 br3" {{ with .FilterTitle }}data-filter-title="{{ . }}"{{ end }}>
<div class="timeline-item flex flex-column pa3 mb2 br3" data-id="{{ .ID }}" {{ with .FilterTitle }}data-filter-title="{{ . }}"{{ end }}>
{{/* top bar - avatar, info, date */}}
<div class="flex items-center">
{{ if .OwnerAvatarUrl }}
@ -28,12 +28,16 @@
{{ if .SmallInfo }}
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
{{ end }}
{{ if .Editable }}
<a href="javascript:;" class="edit ml2">&#9998;</a>
<div class="dn rawdesc">{{ .RawDescription }}</div>
{{ end }}
</div>
{{/* content */}}
{{ if .Description }}
<div class="mt3 overflow-hidden relative {{ if .TruncateDescription }}mh-5{{ end }}">
<div class="mt3 overflow-hidden relative pre-line {{ if .TruncateDescription }}mh-5{{ end }}">
<div class="post-content">{{ .Description }}</div>
{{ if .TruncateDescription }}
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
@ -64,12 +68,13 @@
</div>
{{ end }}
{{ with .Tags }}
<div class="mt3 flex">
{{ range $i, $tag := . }}
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
{{ $tag.Text }}
</div>
{{ with .Projects }}
<div class="mt3 flex g2 projects">
{{ range $i, $proj := . }}
<a data-projid="{{ $proj.ID }}" href="{{ $proj.Url }}" class="flex flex-row items-center bg-theme-dimmer ph2 pv1 br2">
<img src="{{ $proj.Logo }}" class="db mr1 br1 h1-5" />
<div>{{ $proj.Name }}</div>
</a>
{{ end }}
</div>
{{ end }}

View File

@ -4,6 +4,7 @@
{{ range .Screenshots }}
<link rel="preload" href="{{ . }}" as="image">
{{ end }}
<script src="{{ static "js/templates.js" }}"></script>
{{ end }}
{{ define "content" }}
@ -74,11 +75,17 @@
{{ .Project.Blurb }}
{{ end }}
</div>
{{ with .RecentActivity }}
{{ if or .Header.Project.CanEdit (gt (len .RecentActivity) 0) }}
<div class="content-block timeline-container ph3 ph0-ns mv4">
<h2>Recent Activity</h2>
<div class="flex flex-row items-center mb2">
<h2>Recent Activity</h2>
<div class="flex-grow-1"></div>
{{ if .Header.Project.CanEdit }}
<a href="javascript:;" class="create_snippet_link button">Add Snippet</a>
{{ end }}
</div>
<div class="timeline">
{{ range . }}
{{ range .RecentActivity }}
{{ template "timeline_item.html" . }}
{{ end }}
</div>
@ -86,6 +93,35 @@
{{ end }}
</div>
</div>
{{ if .User }}
{{ template "snippet_edit.html" . }}
{{ if .Header.Project.CanEdit }}
<script>
const userName = "{{ .User.Name }}";
const userAvatar = "{{ .User.AvatarUrl }}";
const userUrl = "{{ .User.ProfileUrl }}";
const currentProjectId = {{ .Project.ID }};
document.querySelector(".create_snippet_link")?.addEventListener("click", function() {
let snippetEdit = makeSnippetEdit(userName, userAvatar, userUrl, new Date(), "", null, [currentProjectId], null, null);
document.querySelector(".timeline").insertBefore(snippetEdit.root, document.querySelector(".timeline").children[0]);
document.querySelector(".create_snippet_link")?.remove();
});
document.querySelector(".timeline").addEventListener("click", function(ev) {
if (ev.target.classList.contains("edit")) {
let parent = ev.target.parentElement;
while (parent && !parent.classList.contains("timeline-item")) {
parent = parent.parentElement;
}
if (parent && parent.classList.contains("timeline-item")) {
editTimelineSnippet(parent);
}
}
});
</script>
{{ end }}
{{ end }}
<script>
const numCarouselItems = {{ len .Screenshots }};

View File

@ -1,42 +1,27 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
<script src="{{ static "js/templates.js" }}"></script>
{{ end }}
{{ define "content" }}
<div class="content-block">
<div class="flex flex-column pa3 mb2 br3">
<div class="mb2 flex items-center">
<img class="avatar-icon lite mr2" src="{{ .Snippet.OwnerAvatarUrl }}"/>
<a class="user" href="{{ .Snippet.OwnerUrl }}">{{ .Snippet.OwnerName }}</a>
<a class="tr" style="flex: 1 1 auto;" href="{{ .Snippet.Url }}">{{ timehtml (relativedate .Snippet.Date) .Snippet.Date }}</a>
</div>
<div class="pre overflow-auto mb2">{{ .Snippet.Description }}</div>
<div>
{{ range .Snippet.EmbedMedia }}
{{ if eq .Type mediaimage }}
<img src="{{ .AssetUrl }}">
{{ else if eq .Type mediavideo }}
<video src="{{ .AssetUrl }}" preload="metadata" controls>
{{ else if eq .Type mediaaudio }}
<audio src="{{ .AssetUrl }}" controls>
{{ else if eq .Type mediaembed }}
<div class="mb3 aspect-ratio aspect-ratio--16x9">
{{ .EmbedHTML }}
</div>
{{ else }}
<div class="project-card br2 pv1 ph2">
<a href="{{ .AssetUrl }}" target="_blank">{{ .Filename }} ({{ filesize .FileSize }})</a>
</div>
{{ end }}
{{ end }}
</div>
{{ with .Snippet.Tags }}
<div class="mt3 flex">
{{ range $i, $tag := . }}
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
{{ $tag.Text }}
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ template "timeline_item.html" .Snippet }}
</div>
{{ if .CanEditSnippet }}
{{ template "snippet_edit.html" . }}
<script>
document.querySelector(".timeline-item .edit").addEventListener("click", function(ev) {
if (ev.target.classList.contains("edit")) {
let parent = ev.target.parentElement;
while (parent && !parent.classList.contains("timeline-item")) {
parent = parent.parentElement;
}
if (parent && parent.classList.contains("timeline-item")) {
editTimelineSnippet(parent);
}
}
});
</script>
{{ end }}
{{ end }}

View File

@ -26,6 +26,7 @@
}
</style>
<script src="{{ static "js/templates.js" }}"></script>
{{ end }}
{{ define "content" }}
@ -143,19 +144,15 @@
{{ end }}
</div>
{{ end }}
{{ if eq 1 0 }}
<div class="content-block ph3 ph0-ns">
<h2>Add Snippets</h2>
<div class="note">
Show us what you're working on.<br />
You can upload videos, images, and audio clips.<br />
Your snippets may appear on the <a href="{{ .ShowcaseUrl }}">showcase page</a>.
</div>
</div>
{{ end }}
{{ if gt (len .TimelineItems) 0 }}
{{ if or .OwnProfile (gt (len .TimelineItems) 0) }}
<div class="mv3 content-block timeline-container ph3 ph0-ns">
<h2>Recent Activity</h2>
<div class="flex flex-row items-center">
<h2>Recent Activity</h2>
<div class="flex-grow-1"></div>
{{ if .OwnProfile }}
<a href="javascript:;" class="create_snippet_link button">Add Snippet</a>
{{ end }}
</div>
<div class="timeline-filters mb2">
</div>
<div class="timeline">
@ -167,6 +164,36 @@
{{ end }}
</div>
</div>
{{ if .User }}
{{ template "snippet_edit.html" . }}
<script>
const userName = "{{ .User.Name }}";
const userAvatar = "{{ .User.AvatarUrl }}";
const userUrl = "{{ .User.ProfileUrl }}";
{{ if .OwnProfile }}
document.querySelector(".create_snippet_link")?.addEventListener("click", function() {
let snippetEdit = makeSnippetEdit(userName, userAvatar, userUrl, new Date(), "", null, [], null, null);
document.querySelector(".timeline").insertBefore(snippetEdit.root, document.querySelector(".timeline").children[0]);
document.querySelector(".create_snippet_link")?.remove();
});
{{ end }}
document.querySelector(".timeline").addEventListener("click", function(ev) {
if (ev.target.classList.contains("edit")) {
let parent = ev.target.parentElement;
while (parent && !parent.classList.contains("timeline-item")) {
parent = parent.parentElement;
}
if (parent && parent.classList.contains("timeline-item")) {
editTimelineSnippet(parent);
}
}
});
</script>
{{ end }}
<script>
const filterTitles = [];
for (const item of document.querySelectorAll('.timeline-item')) {

View File

@ -4,6 +4,7 @@ import (
"embed"
"fmt"
"html/template"
"regexp"
"strings"
"time"
@ -83,6 +84,8 @@ func names(ts []*template.Template) []string {
//go:embed svg/*
var SVGs embed.FS
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
var HMNTemplateFuncs = template.FuncMap{
"add": func(a int, b ...int) int {
for _, num := range b {
@ -220,6 +223,9 @@ var HMNTemplateFuncs = template.FuncMap{
}
return fmt.Sprintf("%.*f%s", precision, num, scales[scale])
},
"cleancontrolchars": func(str template.HTML) template.HTML {
return template.HTML(controlCharRegex.ReplaceAllString(string(str), ""))
},
// NOTE(asaf): Template specific functions:
"projectcarddata": func(project Project, classes string) ProjectCardData {

View File

@ -115,6 +115,7 @@ type Post struct {
}
type Project struct {
ID int
Name string
Subdomain string
Color1 string
@ -163,6 +164,12 @@ type ProjectJamParticipation struct {
Participating bool
}
type SnippetEdit struct {
AvailableProjectsJSON string
SubmitUrl string
AssetMaxSize int
}
type User struct {
ID int
Username string
@ -287,6 +294,7 @@ type ThreadListItem struct {
}
type TimelineItem struct {
ID string
Date time.Time
Title string
TypeTitle string
@ -299,8 +307,9 @@ type TimelineItem struct {
OwnerName string
OwnerUrl string
Tags []Tag
Description template.HTML
Projects []Project
Description template.HTML
RawDescription string
PreviewMedia TimelineItemMedia
EmbedMedia []TimelineItemMedia
@ -309,6 +318,7 @@ type TimelineItem struct {
AllowTitleWrap bool
TruncateDescription bool
CanShowcase bool // whether this snippet can be shown in a showcase gallery
Editable bool
}
type TimelineItemMediaType int

View File

@ -26,7 +26,7 @@ const assetMaxSize = 10 * 1024 * 1024
const assetMaxSizeAdmin = 10 * 1024 * 1024 * 1024
func AssetMaxSize(user *models.User) int {
if user.IsStaff {
if user != nil && user.IsStaff {
return assetMaxSizeAdmin
} else {
return assetMaxSize

View File

@ -195,7 +195,7 @@ func AtomFeed(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
}
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
feedData.Snippets = append(feedData.Snippets, timelineItem)
}
c.Perf.EndBlock()

View File

@ -14,9 +14,6 @@ import (
func JamIndex2022(c *RequestContext) ResponseData {
var res ResponseData
// If logged in, fetch jam project
// Link to project page if found, otherwise link to project creation page with ?jam=1
daysUntilStart := daysUntil(hmndata.WRJ2022.StartTime)
daysUntilEnd := daysUntil(hmndata.WRJ2022.EndTime)
@ -64,23 +61,21 @@ func JamIndex2022(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
}
jamProjectTags := make([]int, 0, len(jamProjects))
projectIds := make([]int, 0, len(jamProjects))
for _, jp := range jamProjects {
if jp.Tag != nil {
jamProjectTags = append(jamProjectTags, jp.Tag.ID)
}
projectIds = append(projectIds, jp.Project.ID)
}
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
Tags: jamProjectTags,
Limit: 12,
ProjectIDs: projectIds,
Limit: 12,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for jam showcase"))
}
showcaseItems = make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}
@ -110,15 +105,13 @@ func JamFeed2022(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
}
jamProjectTags := make([]int, 0, len(jamProjects))
projectIds := make([]int, 0, len(jamProjects))
for _, jp := range jamProjects {
if jp.Tag != nil {
jamProjectTags = append(jamProjectTags, jp.Tag.ID)
}
projectIds = append(projectIds, jp.Project.ID)
}
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
Tags: jamProjectTags,
ProjectIDs: projectIds,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for jam showcase"))
@ -127,9 +120,8 @@ func JamFeed2022(c *RequestContext) ResponseData {
timelineItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
timelineItem.SmallInfo = true
timelineItem.Tags = nil
timelineItems = append(timelineItems, timelineItem)
}
@ -198,7 +190,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
}
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}

View File

@ -114,7 +114,7 @@ func Index(c *RequestContext) ResponseData {
}
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}

View File

@ -210,6 +210,7 @@ type ProjectHomepageData struct {
ProjectLinks []templates.Link
Licenses []templates.Link
RecentActivity []templates.TimelineItem
SnippetEdit templates.SnippetEdit
}
func ProjectHomepage(c *RequestContext) ResponseData {
@ -353,13 +354,8 @@ func ProjectHomepage(c *RequestContext) ResponseData {
))
}
tagId := -1
if c.CurrentProject.TagID != nil {
tagId = *c.CurrentProject.TagID
}
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
Tags: []int{tagId},
ProjectIDs: []int{c.CurrentProject.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
@ -369,9 +365,10 @@ func ProjectHomepage(c *RequestContext) ResponseData {
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Tags,
s.Projects,
s.Owner,
c.Theme,
(c.CurrentUser != nil && (s.Owner.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
)
item.SmallInfo = true
templateData.RecentActivity = append(templateData.RecentActivity, item)
@ -383,6 +380,25 @@ func ProjectHomepage(c *RequestContext) ResponseData {
})
c.Perf.EndBlock()
if c.CurrentUser != nil {
userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
}
templateProjects := make([]templates.Project, 0, len(userProjects))
for _, p := range userProjects {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProjects = append(templateProjects, templateProject)
}
templateData.SnippetEdit = templates.SnippetEdit{
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
SubmitUrl: hmnurl.BuildSnippetSubmit(),
AssetMaxSize: AssetMaxSize(c.CurrentUser),
}
}
var res ResponseData
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
if err != nil {

View File

@ -196,7 +196,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
attachProjectRoutes(&officialProjectRoutes)
attachProjectRoutes(&personalProjectRoutes)
// TODO(ben): Uh, should these all be pulled into the project route group above...?
anyProject.POST(hmnurl.RegexSnippetSubmit, needsAuth(csrfMiddleware(SnippetEditSubmit)))
anyProject.GET(hmnurl.RegexEpisodeList, EpisodeList)
anyProject.GET(hmnurl.RegexEpisode, Episode)
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)

View File

@ -23,7 +23,7 @@ func Showcase(c *RequestContext) ResponseData {
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem)
}

View File

@ -1,20 +1,36 @@
package website
import (
"bytes"
"errors"
"fmt"
"image"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/embed"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/google/uuid"
"mvdan.cc/xurls/v2"
)
type SnippetData struct {
templates.BaseData
Snippet templates.TimelineItem
CanEditSnippet bool
SnippetEdit templates.SnippetEdit
}
func Snippet(c *RequestContext) ResponseData {
@ -40,7 +56,9 @@ func Snippet(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
canEdit := (c.CurrentUser != nil && (c.CurrentUser.IsStaff || c.CurrentUser.ID == s.Owner.ID))
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, canEdit)
snippet.SmallInfo = true
opengraph := []templates.OpenGraphItem{
{Property: "og:site_name", Value: "Handmade.Network"},
@ -86,13 +104,290 @@ func Snippet(c *RequestContext) ResponseData {
[]templates.Breadcrumb{{Name: snippet.OwnerName, Url: snippet.OwnerUrl}},
)
baseData.OpenGraphItems = opengraph // NOTE(asaf): We're overriding the defaults on purpose.
snippetEdit := templates.SnippetEdit{}
if c.CurrentUser != nil {
userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
}
templateProjects := make([]templates.Project, 0, len(userProjects))
for _, p := range userProjects {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProjects = append(templateProjects, templateProject)
}
snippetEdit = templates.SnippetEdit{
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
SubmitUrl: hmnurl.BuildSnippetSubmit(),
AssetMaxSize: AssetMaxSize(c.CurrentUser),
}
}
var res ResponseData
err = res.WriteTemplate("snippet.html", SnippetData{
BaseData: baseData,
Snippet: snippet,
BaseData: baseData,
Snippet: snippet,
CanEditSnippet: canEdit,
SnippetEdit: snippetEdit,
}, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render snippet template"))
}
return res
}
func SnippetEditSubmit(c *RequestContext) ResponseData {
maxUploadSize := AssetMaxSize(c.CurrentUser)
maxBodySize := int64(maxUploadSize + 1024*1024)
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
err := c.Req.ParseMultipartForm(maxBodySize)
if err != nil {
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
}
form := c.Req.PostForm
redirect := form.Get("redirect")
action := form.Get("action")
existingSnippetIdStr := strings.TrimSpace(form.Get("snippet_id"))
var existingSnippet *hmndata.SnippetAndStuff
originalText := ""
var embedUrl *string
var assetID *uuid.UUID
if len(existingSnippetIdStr) > 0 {
existingSnippetId, err := strconv.Atoi(existingSnippetIdStr)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse snippet id"))
}
query := hmndata.SnippetQuery{}
if !c.CurrentUser.IsStaff {
query.OwnerIDs = []int{c.CurrentUser.ID}
}
snip, err := hmndata.FetchSnippet(c, c.Conn, c.CurrentUser, existingSnippetId, query)
if err != nil {
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit"))
}
}
originalText = snip.Snippet.Description
embedUrl = snip.Snippet.Url
assetID = snip.Snippet.AssetID
if snip.Snippet.Url != nil {
embedUrl = snip.Snippet.Url
}
existingSnippet = &snip
}
if strings.ToLower(action) == "delete" {
if existingSnippet != nil {
_, err = c.Conn.Exec(c,
`
DELETE FROM snippet
WHERE snippet.id = $1
`,
existingSnippet.Snippet.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit"))
}
} else {
return FourOhFour(c)
}
} else {
if form.Get("remove_attachment") == "true" {
embedUrl = nil
assetID = nil
}
text := strings.TrimSpace(form.Get("text"))
textHtml := parsing.ParseMarkdown(text, parsing.DiscordMarkdown)
projectAssociations := form["project_id"]
var assetData *assets.CreateInput
file, header, err := c.Req.FormFile("file")
if err != nil && !errors.Is(err, http.ErrMissingFile) {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get form file"))
}
if header != nil {
content := make([]byte, header.Size)
_, err = file.Read(content)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(content)
}
width := 0
height := 0
if strings.HasPrefix(contentType, "image/") && contentType != "image/svg+xml" {
file.Seek(0, io.SeekStart)
config, _, err := image.DecodeConfig(file)
if err == nil {
width = config.Width
height = config.Height
}
}
assetData = &assets.CreateInput{
Content: content,
Filename: header.Filename,
ContentType: contentType,
UploaderID: &c.CurrentUser.ID,
Width: width,
Height: height,
}
}
if originalText != text && assetData == nil && embedUrl == nil && assetID == nil {
urls := xurls.Relaxed().FindAllString(text, -1)
if urls != nil {
embeddable, err := embed.GetEmbeddableFromUrls(c, urls, maxUploadSize, time.Second*10, 3)
if err != nil {
if !errors.Is(err, embed.DownloadTooBigError) && !errors.Is(err, embed.NoEmbedFound) {
c.Logger.Error().Err(err).Msg("failed to fetch embeddable for snippet")
}
} else {
if embeddable.Url != "" {
embedUrl = &embeddable.Url
} else {
width := 0
height := 0
if strings.HasPrefix(embeddable.File.ContentType, "image/") && embeddable.File.ContentType != "image/svg+xml" {
reader := bytes.NewReader(embeddable.File.Data)
config, _, err := image.DecodeConfig(reader)
if err == nil {
width = config.Width
height = config.Height
}
}
assetData = &assets.CreateInput{
Content: embeddable.File.Data,
Filename: embeddable.File.Filename,
ContentType: embeddable.File.ContentType,
UploaderID: &c.CurrentUser.ID,
Width: width,
Height: height,
}
}
}
}
}
if text == "" && assetData == nil && embedUrl == nil && assetID == nil {
return c.RejectRequest("You must provide a description or a file attachment.")
}
tx, err := c.Conn.Begin(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start transaction"))
}
defer tx.Rollback(c)
var asset *models.Asset
if assetData != nil {
asset, err = assets.Create(c, tx, *assetData)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create asset"))
}
assetID = &asset.ID
}
snippetId := 0
if existingSnippet != nil {
_, err = tx.Exec(c,
`
UPDATE snippet SET
url = $2,
description = $3,
_description_html = $4,
asset_id = $5,
edited_on_website = $6
WHERE id = $1
`,
existingSnippet.Snippet.ID,
embedUrl,
text,
textHtml,
assetID,
true,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update snippet"))
}
snippetId = existingSnippet.Snippet.ID
} else {
newSnippetId, err := db.QueryOne[int](c, tx,
`
INSERT INTO snippet (url, "when", description, _description_html, asset_id, owner_id, edited_on_website)
VALUES ($1, $2, $3, $4, $5, $6 ,$7)
RETURNING id
`,
embedUrl,
time.Now(),
text,
textHtml,
assetID,
c.CurrentUser.ID,
true,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to insert snippet"))
}
snippetId = *newSnippetId
}
_, err = tx.Exec(c,
`
DELETE FROM snippet_project
WHERE snippet_id = $1
`,
snippetId,
)
if len(projectAssociations) > 0 {
var projectIds []int
for _, pidStr := range projectAssociations {
projId, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
projectIds = append(projectIds, projId)
}
if len(projectIds) > 0 {
projectQuery := hmndata.ProjectsQuery{
ProjectIDs: projectIds,
}
if !c.CurrentUser.IsStaff {
projectQuery.OwnerIDs = []int{c.CurrentUser.ID}
}
projects, err := hmndata.FetchProjects(c, tx, c.CurrentUser, projectQuery)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for snippet"))
}
for _, p := range projects {
_, err = tx.Exec(c,
`
INSERT INTO snippet_project (snippet_id, project_id, kind)
VALUES ($1, $2, $3)
`,
snippetId,
p.Project.ID,
models.SnippetProjectKindWebsite,
)
}
}
}
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
}
}
return c.Redirect(redirect, http.StatusSeeOther)
}

View File

@ -5,8 +5,10 @@ import (
"html/template"
"regexp"
"sort"
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
@ -64,11 +66,13 @@ func SnippetToTimelineItem(
snippet *models.Snippet,
asset *models.Asset,
discordMessage *models.DiscordMessage,
tags []*models.Tag,
projects []*hmndata.ProjectAndStuff,
owner *models.User,
currentTheme string,
editable bool,
) templates.TimelineItem {
item := templates.TimelineItem{
ID: strconv.Itoa(snippet.ID),
Date: snippet.When,
FilterTitle: "Snippets",
Url: hmnurl.BuildSnippet(snippet.ID),
@ -77,9 +81,11 @@ func SnippetToTimelineItem(
OwnerName: owner.BestName(),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: template.HTML(snippet.DescriptionHtml),
Description: template.HTML(snippet.DescriptionHtml),
RawDescription: snippet.Description,
CanShowcase: true,
Editable: editable,
}
if asset != nil {
@ -111,11 +117,11 @@ func SnippetToTimelineItem(
item.DiscordMessageUrl = discordMessage.Url
}
sort.Slice(tags, func(i, j int) bool {
return tags[i].Text < tags[j].Text
sort.Slice(projects, func(i, j int) bool {
return projects[i].Project.Name < projects[j].Project.Name
})
for _, tag := range tags {
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
for _, proj := range projects {
item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj, hmndata.UrlContextForProject(&proj.Project).BuildHomepage(), currentTheme))
}
return item

View File

@ -37,6 +37,8 @@ type UserProfileTemplateData struct {
AdminSetStatusUrl string
AdminNukeUrl string
SnippetEdit templates.SnippetEdit
}
func UserProfile(c *RequestContext) ResponseData {
@ -148,9 +150,10 @@ func UserProfile(c *RequestContext) ResponseData {
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Tags,
s.Projects,
profileUser,
c.Theme,
(c.CurrentUser != nil && (profileUser.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
)
item.SmallInfo = true
timelineItems = append(timelineItems, item)
@ -168,6 +171,15 @@ func UserProfile(c *RequestContext) ResponseData {
baseData := getBaseDataAutocrumb(c, templateUser.Name)
snippetEdit := templates.SnippetEdit{}
if c.CurrentUser != nil {
snippetEdit = templates.SnippetEdit{
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
SubmitUrl: hmnurl.BuildSnippetSubmit(),
AssetMaxSize: AssetMaxSize(c.CurrentUser),
}
}
var res ResponseData
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
BaseData: baseData,
@ -183,6 +195,8 @@ func UserProfile(c *RequestContext) ResponseData {
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
SnippetEdit: snippetEdit,
}, c.Perf)
return res
}