Snippet creation and editing
This commit is contained in:
parent
41c2b6e111
commit
efc7d76cb7
1
go.mod
1
go.mod
|
@ -62,6 +62,7 @@ require (
|
||||||
golang.org/x/text v0.3.6 // indirect
|
golang.org/x/text v0.3.6 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||||
|
mvdan.cc/xurls/v2 v2.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace (
|
replace (
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -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/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/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/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.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/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=
|
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/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/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.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.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
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-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.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
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=
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
|
|
@ -8,7 +8,7 @@ const TimelineMediaTypes = {
|
||||||
|
|
||||||
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
||||||
const modalTemplate = makeTemplateCloner("timeline_modal");
|
const modalTemplate = makeTemplateCloner("timeline_modal");
|
||||||
const tagTemplate = makeTemplateCloner("timeline_item_tag");
|
const projectLinkTemplate = makeTemplateCloner("project_link");
|
||||||
|
|
||||||
function showcaseTimestamp(rawDate) {
|
function showcaseTimestamp(rawDate) {
|
||||||
const date = new Date(rawDate*1000);
|
const date = new Date(rawDate*1000);
|
||||||
|
@ -97,14 +97,16 @@ function makeShowcaseItem(timelineItem) {
|
||||||
modalEl.date.textContent = timestamp;
|
modalEl.date.textContent = timestamp;
|
||||||
modalEl.date.setAttribute("href", timelineItem.snippet_url);
|
modalEl.date.setAttribute("href", timelineItem.snippet_url);
|
||||||
|
|
||||||
if (timelineItem.tags.length === 0) {
|
if (timelineItem.projects.length === 0) {
|
||||||
modalEl.tags.remove();
|
modalEl.projects.remove();
|
||||||
} else {
|
} else {
|
||||||
for (const tag of timelineItem.tags) {
|
for (const proj of timelineItem.projects) {
|
||||||
const tagItem = tagTemplate();
|
const projectLink = projectLinkTemplate();
|
||||||
tagItem.tag.innerText = tag.text;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -7461,6 +7461,30 @@ article code {
|
||||||
.minh-6 {
|
.minh-6 {
|
||||||
min-height: 32rem; }
|
min-height: 32rem; }
|
||||||
|
|
||||||
|
.h1-5 {
|
||||||
|
height: 1.5rem; }
|
||||||
|
|
||||||
|
.gap0 {
|
||||||
|
gap: 0; }
|
||||||
|
|
||||||
|
.gap1 {
|
||||||
|
gap: 0.25rem; }
|
||||||
|
|
||||||
|
.gap2 {
|
||||||
|
gap: 0.5rem; }
|
||||||
|
|
||||||
|
.gap3 {
|
||||||
|
gap: 1rem; }
|
||||||
|
|
||||||
|
.gap4 {
|
||||||
|
gap: 2rem; }
|
||||||
|
|
||||||
|
.gap5 {
|
||||||
|
gap: 4rem; }
|
||||||
|
|
||||||
|
.pre-line {
|
||||||
|
white-space: pre-line; }
|
||||||
|
|
||||||
.fira {
|
.fira {
|
||||||
font-family: "Fira Sans", sans-serif; }
|
font-family: "Fira Sans", sans-serif; }
|
||||||
|
|
||||||
|
|
|
@ -466,5 +466,48 @@ func init() {
|
||||||
moveThreadsToSubforumCommand.MarkFlagRequired("subforum_slug")
|
moveThreadsToSubforumCommand.MarkFlagRequired("subforum_slug")
|
||||||
adminCommand.AddCommand(moveThreadsToSubforumCommand)
|
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)
|
addProjectCommands(adminCommand)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
// Match only tags for projects in which the current user is a collaborator.
|
||||||
messageTags := getDiscordTags(existingSnippet.Description)
|
messageTags := getDiscordTags(existingSnippet.Description)
|
||||||
|
|
||||||
var desiredTags []int
|
|
||||||
var allTags []int
|
|
||||||
|
|
||||||
// Fetch projects so we know what tags the user can apply to their snippet.
|
// Fetch projects so we know what tags the user can apply to their snippet.
|
||||||
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
|
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
|
||||||
OwnerIDs: []int{interned.HMNUser.ID},
|
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")
|
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,
|
_, err = tx.Exec(ctx,
|
||||||
`
|
`
|
||||||
DELETE FROM snippet_tag
|
DELETE FROM snippet_project
|
||||||
WHERE
|
WHERE
|
||||||
snippet_id = $1
|
snippet_id = $1
|
||||||
AND tag_id = ANY ($2)
|
AND kind = $2
|
||||||
`,
|
`,
|
||||||
existingSnippet.ID,
|
existingSnippet.ID,
|
||||||
allTags,
|
models.SnippetProjectKindDiscord,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 {
|
for _, p := range projects {
|
||||||
|
if p.Tag != nil {
|
||||||
|
for _, messageTag := range messageTags {
|
||||||
|
if strings.EqualFold(p.Tag.Text, messageTag) {
|
||||||
_, err = tx.Exec(ctx,
|
_, err = tx.Exec(ctx,
|
||||||
`
|
`
|
||||||
INSERT INTO snippet_tag (snippet_id, tag_id)
|
INSERT INTO snippet_project (project_id, snippet_id, kind)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`,
|
`,
|
||||||
|
p.Project.ID,
|
||||||
existingSnippet.ID,
|
existingSnippet.ID,
|
||||||
tagID,
|
models.SnippetProjectKindDiscord,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to associate snippet with tag")
|
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
|
// 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?
|
// 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)`)
|
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) {
|
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
|
||||||
|
|
|
@ -0,0 +1,199 @@
|
||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
|
"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
|
||||||
|
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, httpTimeout, 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, timeout time.Duration, maxSize int) (*Embed, error) {
|
||||||
|
logging.ExtractLogger(ctx).Debug().Msg("Fetching embed")
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||||
|
return nil, NoEmbedFound
|
||||||
|
}
|
||||||
|
contentType := res.Header.Get("Content-Type")
|
||||||
|
logging.ExtractLogger(ctx).Debug().Str("type", contentType).Msg("Got first result")
|
||||||
|
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.
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
partialHtml := buffer.Bytes()
|
||||||
|
urlStr = ExtractEmbedFromOpenGraph(partialHtml)
|
||||||
|
logging.ExtractLogger(ctx).Debug().Str("url", urlStr).Msg("Got ograph")
|
||||||
|
if urlStr == "" {
|
||||||
|
return nil, NoEmbedFound
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
res.Body.Close()
|
||||||
|
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)
|
||||||
|
matches := metaRegex.FindAllStringSubmatch(html, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) > 1 {
|
||||||
|
content := ""
|
||||||
|
prop := ""
|
||||||
|
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 {
|
||||||
|
prop = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if key == "content" {
|
||||||
|
content = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content != "" && prop != "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import (
|
||||||
type SnippetQuery struct {
|
type SnippetQuery struct {
|
||||||
IDs []int
|
IDs []int
|
||||||
OwnerIDs []int
|
OwnerIDs []int
|
||||||
|
ProjectIDs []int
|
||||||
Tags []int
|
Tags []int
|
||||||
DiscordMessageIDs []string
|
DiscordMessageIDs []string
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ type SnippetAndStuff struct {
|
||||||
Asset *models.Asset `db:"asset"`
|
Asset *models.Asset `db:"asset"`
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||||
Tags []*models.Tag
|
Tags []*models.Tag
|
||||||
|
Projects []*ProjectAndStuff
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchSnippets(
|
func FetchSnippets(
|
||||||
|
@ -42,6 +44,7 @@ func FetchSnippets(
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var tagSnippetIDs []int
|
||||||
if len(q.Tags) > 0 {
|
if len(q.Tags) > 0 {
|
||||||
// Get snippet IDs with this tag, then use that in the main query
|
// Get snippet IDs with this tag, then use that in the main query
|
||||||
snippetIDs, err := db.QueryScalar[int](ctx, tx,
|
snippetIDs, err := db.QueryScalar[int](ctx, tx,
|
||||||
|
@ -64,7 +67,32 @@ func FetchSnippets(
|
||||||
return nil, nil
|
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
|
var qb db.QueryBuilder
|
||||||
|
@ -74,7 +102,7 @@ func FetchSnippets(
|
||||||
FROM
|
FROM
|
||||||
snippet
|
snippet
|
||||||
LEFT JOIN hmn_user AS owner ON snippet.owner_id = owner.id
|
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 asset ON snippet.asset_id = asset.id
|
||||||
LEFT JOIN discord_message ON snippet.discord_message_id = discord_message.id
|
LEFT JOIN discord_message ON snippet.discord_message_id = discord_message.id
|
||||||
WHERE
|
WHERE
|
||||||
|
@ -84,6 +112,12 @@ func FetchSnippets(
|
||||||
if len(q.IDs) > 0 {
|
if len(q.IDs) > 0 {
|
||||||
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
|
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 {
|
if len(q.OwnerIDs) > 0 {
|
||||||
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||||
}
|
}
|
||||||
|
@ -115,6 +149,7 @@ func FetchSnippets(
|
||||||
type resultRow struct {
|
type resultRow struct {
|
||||||
Snippet models.Snippet `db:"snippet"`
|
Snippet models.Snippet `db:"snippet"`
|
||||||
Owner *models.User `db:"owner"`
|
Owner *models.User `db:"owner"`
|
||||||
|
AvatarAsset *models.Asset `db:"avatar"`
|
||||||
Asset *models.Asset `db:"asset"`
|
Asset *models.Asset `db:"asset"`
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||||
}
|
}
|
||||||
|
@ -127,6 +162,9 @@ func FetchSnippets(
|
||||||
result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
|
result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
|
||||||
snippetIDs := make([]int, len(results))
|
snippetIDs := make([]int, len(results))
|
||||||
for i, row := range results {
|
for i, row := range results {
|
||||||
|
if results[i].Owner != nil {
|
||||||
|
results[i].Owner.AvatarAsset = results[i].AvatarAsset
|
||||||
|
}
|
||||||
result[i] = SnippetAndStuff{
|
result[i] = SnippetAndStuff{
|
||||||
Snippet: row.Snippet,
|
Snippet: row.Snippet,
|
||||||
Owner: row.Owner,
|
Owner: row.Owner,
|
||||||
|
@ -167,6 +205,42 @@ func FetchSnippets(
|
||||||
item.Tags = append(item.Tags, snippetTag.Tag)
|
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)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to commit transaction")
|
return nil, oops.New(err, "failed to commit transaction")
|
||||||
|
|
|
@ -120,6 +120,10 @@ func TestSnippet(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
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) {
|
func TestFeed(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildFeed(), RegexFeed, nil)
|
AssertRegexMatch(t, BuildFeed(), RegexFeed, nil)
|
||||||
assert.Equal(t, BuildFeed(), BuildFeedWithPage(1))
|
assert.Equal(t, BuildFeed(), BuildFeedWithPage(1))
|
||||||
|
|
|
@ -262,6 +262,13 @@ func BuildSnippet(snippetId int) string {
|
||||||
return Url("/snippet/"+strconv.Itoa(snippetId), nil)
|
return Url("/snippet/"+strconv.Itoa(snippetId), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexSnippetSubmit = regexp.MustCompile(`^/snippet$`)
|
||||||
|
|
||||||
|
func BuildSnippetSubmit() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/snippet", nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Feed
|
* Feed
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -6,6 +6,13 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SnippetProjectAssociationKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SnippetProjectKindDiscord SnippetProjectAssociationKind = iota + 1
|
||||||
|
SnippetProjectKindWebsite
|
||||||
|
)
|
||||||
|
|
||||||
type Snippet struct {
|
type Snippet struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
OwnerID int `db:"owner_id"`
|
OwnerID int `db:"owner_id"`
|
||||||
|
|
|
@ -333,6 +333,38 @@ article code {
|
||||||
min-height: $height-6;
|
min-height: $height-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h1-5 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap0 {
|
||||||
|
gap: $spacing-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap1 {
|
||||||
|
gap: $spacing-extra-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap2 {
|
||||||
|
gap: $spacing-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap3 {
|
||||||
|
gap: $spacing-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap4 {
|
||||||
|
gap: $spacing-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap5 {
|
||||||
|
gap: $spacing-extra-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
.fira {
|
.fira {
|
||||||
font-family: "Fira Sans", sans-serif;
|
font-family: "Fira Sans", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,7 @@ func ProjectToTemplate(
|
||||||
url string,
|
url string,
|
||||||
) Project {
|
) Project {
|
||||||
return Project{
|
return Project{
|
||||||
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Subdomain: p.Subdomain(),
|
Subdomain: p.Subdomain(),
|
||||||
Color1: p.Color1,
|
Color1: p.Color1,
|
||||||
|
@ -297,8 +298,6 @@ func LinkToTemplate(link *models.Link) Link {
|
||||||
return tlink
|
return tlink
|
||||||
}
|
}
|
||||||
|
|
||||||
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
|
|
||||||
|
|
||||||
func TimelineItemsToJSON(items []TimelineItem) string {
|
func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
// NOTE(asaf): As of 2021-06-22: This only serializes the data necessary for snippet showcase.
|
// NOTE(asaf): As of 2021-06-22: This only serializes the data necessary for snippet showcase.
|
||||||
builder := strings.Builder{}
|
builder := strings.Builder{}
|
||||||
|
@ -378,22 +377,26 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
builder.WriteString(item.DiscordMessageUrl)
|
builder.WriteString(item.DiscordMessageUrl)
|
||||||
builder.WriteString(`",`)
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
builder.WriteString(`"tags":[`)
|
builder.WriteString(`"projects":[`)
|
||||||
for i, tag := range item.Tags {
|
for j, proj := range item.Projects {
|
||||||
builder.WriteString(`{`)
|
if j > 0 {
|
||||||
|
builder.WriteRune(',')
|
||||||
|
}
|
||||||
|
builder.WriteRune('{')
|
||||||
|
|
||||||
builder.WriteString(`"text":"`)
|
builder.WriteString(`"name":"`)
|
||||||
builder.WriteString(tag.Text)
|
builder.WriteString(proj.Name)
|
||||||
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
|
builder.WriteString(`"logo":"`)
|
||||||
|
builder.WriteString(proj.Logo)
|
||||||
builder.WriteString(`",`)
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
builder.WriteString(`"url":"`)
|
builder.WriteString(`"url":"`)
|
||||||
builder.WriteString(tag.Url)
|
builder.WriteString(proj.Url)
|
||||||
builder.WriteString(`"`)
|
builder.WriteString(`"`)
|
||||||
|
|
||||||
builder.WriteString(`}`)
|
builder.WriteRune('}')
|
||||||
if i < len(item.Tags)-1 {
|
|
||||||
builder.WriteString(`,`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
builder.WriteString(`]`)
|
builder.WriteString(`]`)
|
||||||
|
|
||||||
|
@ -403,6 +406,33 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
return builder.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 {
|
func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast {
|
||||||
imageUrl := ""
|
imageUrl := ""
|
||||||
if imageFilename != "" {
|
if imageFilename != "" {
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
<content type="xhtml">
|
<content type="xhtml">
|
||||||
<div xmlns="http://www.w3.org/1999/xhtml">
|
<div xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<div>
|
<div>
|
||||||
{{ .Description }}
|
{{ cleancontrolchars .Description }}
|
||||||
</div>
|
</div>
|
||||||
{{ range .EmbedMedia }}
|
{{ range .EmbedMedia }}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -27,10 +27,10 @@
|
||||||
<a class="user" data-tmpl="userLink"></a>
|
<a class="user" data-tmpl="userLink"></a>
|
||||||
<a data-tmpl="date" class="datetime tr" style="flex: 1 1 auto;"></a>
|
<a data-tmpl="date" class="datetime tr" style="flex: 1 1 auto;"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="pre overflow-auto" data-tmpl="description">
|
<div class="pre-line overflow-auto" data-tmpl="description">
|
||||||
Unknown description
|
Unknown description
|
||||||
</div>
|
</div>
|
||||||
<div data-tmpl="tags" class="pt2 flex"></div>
|
<div data-tmpl="projects" class="pt2 flex gap2"></div>
|
||||||
<div class="i f7 pt2">
|
<div class="i f7 pt2">
|
||||||
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -40,6 +40,9 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="timeline_item_tag">
|
<template id="project_link">
|
||||||
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
|
<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>
|
</template>
|
||||||
|
|
|
@ -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 gap2"></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:;">✖</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
const maxFilesize = {{ .SnippetEdit.AssetMaxSize }};
|
||||||
|
const availableProjects = JSON.parse("{{ .SnippetEdit.AvailableProjectsJSON }}");
|
||||||
|
</script>
|
||||||
|
<script src="{{ static "js/snippetedit.js" }}"></script>
|
||||||
|
|
|
@ -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 */}}
|
{{/* top bar - avatar, info, date */}}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{{ if .OwnerAvatarUrl }}
|
{{ if .OwnerAvatarUrl }}
|
||||||
|
@ -28,12 +28,16 @@
|
||||||
{{ if .SmallInfo }}
|
{{ if .SmallInfo }}
|
||||||
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
|
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .Editable }}
|
||||||
|
<a href="javascript:;" class="edit ml2">✎</a>
|
||||||
|
<div class="dn rawdesc">{{ .RawDescription }}</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* content */}}
|
{{/* content */}}
|
||||||
|
|
||||||
{{ if .Description }}
|
{{ 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>
|
<div class="post-content">{{ .Description }}</div>
|
||||||
{{ if .TruncateDescription }}
|
{{ if .TruncateDescription }}
|
||||||
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
|
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
|
||||||
|
@ -64,12 +68,13 @@
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ with .Tags }}
|
{{ with .Projects }}
|
||||||
<div class="mt3 flex">
|
<div class="mt3 flex gap2 projects">
|
||||||
{{ range $i, $tag := . }}
|
{{ range $i, $proj := . }}
|
||||||
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
<a data-projid="{{ $proj.ID }}" href="{{ $proj.Url }}" class="flex flex-row items-center bg-theme-dimmer ph2 pv1 br2">
|
||||||
{{ $tag.Text }}
|
<img src="{{ $proj.Logo }}" class="db mr1 br1 h1-5" />
|
||||||
</div>
|
<div>{{ $proj.Name }}</div>
|
||||||
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
{{ range .Screenshots }}
|
{{ range .Screenshots }}
|
||||||
<link rel="preload" href="{{ . }}" as="image">
|
<link rel="preload" href="{{ . }}" as="image">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
@ -74,11 +75,17 @@
|
||||||
{{ .Project.Blurb }}
|
{{ .Project.Blurb }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ with .RecentActivity }}
|
{{ if or .Header.Project.CanEdit (gt (len .RecentActivity) 0) }}
|
||||||
<div class="content-block timeline-container ph3 ph0-ns mv4">
|
<div class="content-block timeline-container ph3 ph0-ns mv4">
|
||||||
|
<div class="flex flex-row items-center mb2">
|
||||||
<h2>Recent Activity</h2>
|
<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">
|
<div class="timeline">
|
||||||
{{ range . }}
|
{{ range .RecentActivity }}
|
||||||
{{ template "timeline_item.html" . }}
|
{{ template "timeline_item.html" . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -86,6 +93,35 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
const numCarouselItems = {{ len .Screenshots }};
|
const numCarouselItems = {{ len .Screenshots }};
|
||||||
|
|
||||||
|
|
|
@ -1,42 +1,27 @@
|
||||||
{{ template "base.html" . }}
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<div class="flex flex-column pa3 mb2 br3">
|
{{ template "timeline_item.html" .Snippet }}
|
||||||
<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>
|
</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 }}
|
||||||
{{ 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>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
@ -143,19 +144,15 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if eq 1 0 }}
|
{{ if or .OwnProfile (gt (len .TimelineItems) 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 }}
|
|
||||||
<div class="mv3 content-block timeline-container ph3 ph0-ns">
|
<div class="mv3 content-block timeline-container ph3 ph0-ns">
|
||||||
|
<div class="flex flex-row items-center">
|
||||||
<h2>Recent Activity</h2>
|
<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 class="timeline-filters mb2">
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
|
@ -167,6 +164,36 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
const filterTitles = [];
|
const filterTitles = [];
|
||||||
for (const item of document.querySelectorAll('.timeline-item')) {
|
for (const item of document.querySelectorAll('.timeline-item')) {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -83,6 +84,8 @@ func names(ts []*template.Template) []string {
|
||||||
//go:embed svg/*
|
//go:embed svg/*
|
||||||
var SVGs embed.FS
|
var SVGs embed.FS
|
||||||
|
|
||||||
|
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
|
||||||
|
|
||||||
var HMNTemplateFuncs = template.FuncMap{
|
var HMNTemplateFuncs = template.FuncMap{
|
||||||
"add": func(a int, b ...int) int {
|
"add": func(a int, b ...int) int {
|
||||||
for _, num := range b {
|
for _, num := range b {
|
||||||
|
@ -220,6 +223,9 @@ var HMNTemplateFuncs = template.FuncMap{
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%.*f%s", precision, num, scales[scale])
|
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:
|
// NOTE(asaf): Template specific functions:
|
||||||
"projectcarddata": func(project Project, classes string) ProjectCardData {
|
"projectcarddata": func(project Project, classes string) ProjectCardData {
|
||||||
|
|
|
@ -115,6 +115,7 @@ type Post struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
|
ID int
|
||||||
Name string
|
Name string
|
||||||
Subdomain string
|
Subdomain string
|
||||||
Color1 string
|
Color1 string
|
||||||
|
@ -163,6 +164,12 @@ type ProjectJamParticipation struct {
|
||||||
Participating bool
|
Participating bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SnippetEdit struct {
|
||||||
|
AvailableProjectsJSON string
|
||||||
|
SubmitUrl string
|
||||||
|
AssetMaxSize int
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int
|
ID int
|
||||||
Username string
|
Username string
|
||||||
|
@ -287,6 +294,7 @@ type ThreadListItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineItem struct {
|
type TimelineItem struct {
|
||||||
|
ID string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Title string
|
Title string
|
||||||
TypeTitle string
|
TypeTitle string
|
||||||
|
@ -299,8 +307,9 @@ type TimelineItem struct {
|
||||||
OwnerName string
|
OwnerName string
|
||||||
OwnerUrl string
|
OwnerUrl string
|
||||||
|
|
||||||
Tags []Tag
|
Projects []Project
|
||||||
Description template.HTML
|
Description template.HTML
|
||||||
|
RawDescription string
|
||||||
|
|
||||||
PreviewMedia TimelineItemMedia
|
PreviewMedia TimelineItemMedia
|
||||||
EmbedMedia []TimelineItemMedia
|
EmbedMedia []TimelineItemMedia
|
||||||
|
@ -309,6 +318,7 @@ type TimelineItem struct {
|
||||||
AllowTitleWrap bool
|
AllowTitleWrap bool
|
||||||
TruncateDescription bool
|
TruncateDescription bool
|
||||||
CanShowcase bool // whether this snippet can be shown in a showcase gallery
|
CanShowcase bool // whether this snippet can be shown in a showcase gallery
|
||||||
|
Editable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineItemMediaType int
|
type TimelineItemMediaType int
|
||||||
|
|
|
@ -26,7 +26,7 @@ const assetMaxSize = 10 * 1024 * 1024
|
||||||
const assetMaxSizeAdmin = 10 * 1024 * 1024 * 1024
|
const assetMaxSizeAdmin = 10 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
func AssetMaxSize(user *models.User) int {
|
func AssetMaxSize(user *models.User) int {
|
||||||
if user.IsStaff {
|
if user != nil && user.IsStaff {
|
||||||
return assetMaxSizeAdmin
|
return assetMaxSizeAdmin
|
||||||
} else {
|
} else {
|
||||||
return assetMaxSize
|
return assetMaxSize
|
||||||
|
|
|
@ -195,7 +195,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
for _, s := range 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)
|
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
|
@ -198,7 +198,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
for _, s := range 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 {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,7 +114,7 @@ func Index(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
for _, s := range 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 {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -210,6 +210,7 @@ type ProjectHomepageData struct {
|
||||||
ProjectLinks []templates.Link
|
ProjectLinks []templates.Link
|
||||||
Licenses []templates.Link
|
Licenses []templates.Link
|
||||||
RecentActivity []templates.TimelineItem
|
RecentActivity []templates.TimelineItem
|
||||||
|
SnippetEdit templates.SnippetEdit
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectHomepage(c *RequestContext) ResponseData {
|
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{
|
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||||
Tags: []int{tagId},
|
ProjectIDs: []int{c.CurrentProject.ID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
|
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.Snippet,
|
||||||
s.Asset,
|
s.Asset,
|
||||||
s.DiscordMessage,
|
s.DiscordMessage,
|
||||||
s.Tags,
|
s.Projects,
|
||||||
s.Owner,
|
s.Owner,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
|
(c.CurrentUser != nil && (s.Owner.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
|
||||||
)
|
)
|
||||||
item.SmallInfo = true
|
item.SmallInfo = true
|
||||||
templateData.RecentActivity = append(templateData.RecentActivity, item)
|
templateData.RecentActivity = append(templateData.RecentActivity, item)
|
||||||
|
@ -383,6 +380,25 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
})
|
})
|
||||||
c.Perf.EndBlock()
|
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
|
var res ResponseData
|
||||||
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
|
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -196,7 +196,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
|
||||||
attachProjectRoutes(&officialProjectRoutes)
|
attachProjectRoutes(&officialProjectRoutes)
|
||||||
attachProjectRoutes(&personalProjectRoutes)
|
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.RegexEpisodeList, EpisodeList)
|
||||||
anyProject.GET(hmnurl.RegexEpisode, Episode)
|
anyProject.GET(hmnurl.RegexEpisode, Episode)
|
||||||
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
||||||
|
|
|
@ -23,7 +23,7 @@ func Showcase(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
for _, s := range 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 {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,36 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/assets"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"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/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/oops"
|
||||||
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mvdan.cc/xurls/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SnippetData struct {
|
type SnippetData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
Snippet templates.TimelineItem
|
Snippet templates.TimelineItem
|
||||||
|
|
||||||
|
CanEditSnippet bool
|
||||||
|
SnippetEdit templates.SnippetEdit
|
||||||
}
|
}
|
||||||
|
|
||||||
func Snippet(c *RequestContext) ResponseData {
|
func Snippet(c *RequestContext) ResponseData {
|
||||||
|
@ -40,7 +56,9 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
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{
|
opengraph := []templates.OpenGraphItem{
|
||||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||||
|
@ -86,13 +104,290 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
[]templates.Breadcrumb{{Name: snippet.OwnerName, Url: snippet.OwnerUrl}},
|
[]templates.Breadcrumb{{Name: snippet.OwnerName, Url: snippet.OwnerUrl}},
|
||||||
)
|
)
|
||||||
baseData.OpenGraphItems = opengraph // NOTE(asaf): We're overriding the defaults on purpose.
|
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
|
var res ResponseData
|
||||||
err = res.WriteTemplate("snippet.html", SnippetData{
|
err = res.WriteTemplate("snippet.html", SnippetData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Snippet: snippet,
|
Snippet: snippet,
|
||||||
|
CanEditSnippet: canEdit,
|
||||||
|
SnippetEdit: snippetEdit,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render snippet template"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render snippet template"))
|
||||||
}
|
}
|
||||||
return res
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -5,8 +5,10 @@ import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
|
@ -64,11 +66,13 @@ func SnippetToTimelineItem(
|
||||||
snippet *models.Snippet,
|
snippet *models.Snippet,
|
||||||
asset *models.Asset,
|
asset *models.Asset,
|
||||||
discordMessage *models.DiscordMessage,
|
discordMessage *models.DiscordMessage,
|
||||||
tags []*models.Tag,
|
projects []*hmndata.ProjectAndStuff,
|
||||||
owner *models.User,
|
owner *models.User,
|
||||||
currentTheme string,
|
currentTheme string,
|
||||||
|
editable bool,
|
||||||
) templates.TimelineItem {
|
) templates.TimelineItem {
|
||||||
item := templates.TimelineItem{
|
item := templates.TimelineItem{
|
||||||
|
ID: strconv.Itoa(snippet.ID),
|
||||||
Date: snippet.When,
|
Date: snippet.When,
|
||||||
FilterTitle: "Snippets",
|
FilterTitle: "Snippets",
|
||||||
Url: hmnurl.BuildSnippet(snippet.ID),
|
Url: hmnurl.BuildSnippet(snippet.ID),
|
||||||
|
@ -78,8 +82,10 @@ func SnippetToTimelineItem(
|
||||||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||||
|
|
||||||
Description: template.HTML(snippet.DescriptionHtml),
|
Description: template.HTML(snippet.DescriptionHtml),
|
||||||
|
RawDescription: snippet.Description,
|
||||||
|
|
||||||
CanShowcase: true,
|
CanShowcase: true,
|
||||||
|
Editable: editable,
|
||||||
}
|
}
|
||||||
|
|
||||||
if asset != nil {
|
if asset != nil {
|
||||||
|
@ -111,11 +117,11 @@ func SnippetToTimelineItem(
|
||||||
item.DiscordMessageUrl = discordMessage.Url
|
item.DiscordMessageUrl = discordMessage.Url
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(tags, func(i, j int) bool {
|
sort.Slice(projects, func(i, j int) bool {
|
||||||
return tags[i].Text < tags[j].Text
|
return projects[i].Project.Name < projects[j].Project.Name
|
||||||
})
|
})
|
||||||
for _, tag := range tags {
|
for _, proj := range projects {
|
||||||
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
|
item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj, hmndata.UrlContextForProject(&proj.Project).BuildHomepage(), currentTheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
|
@ -37,6 +37,8 @@ type UserProfileTemplateData struct {
|
||||||
|
|
||||||
AdminSetStatusUrl string
|
AdminSetStatusUrl string
|
||||||
AdminNukeUrl string
|
AdminNukeUrl string
|
||||||
|
|
||||||
|
SnippetEdit templates.SnippetEdit
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserProfile(c *RequestContext) ResponseData {
|
func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
@ -148,9 +150,10 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
&s.Snippet,
|
&s.Snippet,
|
||||||
s.Asset,
|
s.Asset,
|
||||||
s.DiscordMessage,
|
s.DiscordMessage,
|
||||||
s.Tags,
|
s.Projects,
|
||||||
profileUser,
|
profileUser,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
|
(c.CurrentUser != nil && (profileUser.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
|
||||||
)
|
)
|
||||||
item.SmallInfo = true
|
item.SmallInfo = true
|
||||||
timelineItems = append(timelineItems, item)
|
timelineItems = append(timelineItems, item)
|
||||||
|
@ -168,6 +171,15 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
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
|
var res ResponseData
|
||||||
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
@ -183,6 +195,8 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
|
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
|
||||||
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
||||||
|
|
||||||
|
SnippetEdit: snippetEdit,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue