Compare commits
25 Commits
081186c8f0
...
7ea11ebd51
Author | SHA1 | Date |
---|---|---|
Ben Visness | 7ea11ebd51 | |
Ben Visness | 9f88ce1223 | |
Ben Visness | 4289a1e27a | |
Ben Visness | 48490d83a9 | |
Asaf Gartner | 97ed892ce3 | |
Asaf Gartner | 4c1daae5e1 | |
Asaf Gartner | b995df4533 | |
Asaf Gartner | dc94262bfb | |
Asaf Gartner | 841264de0f | |
Asaf Gartner | d2f44f8e5f | |
Asaf Gartner | a9b0606b79 | |
Asaf Gartner | 89b1e48e69 | |
Asaf Gartner | 87a146dfa8 | |
Asaf Gartner | efc7d76cb7 | |
Ben Visness | 41c2b6e111 | |
Ben Visness | 1b79f45d71 | |
Ben Visness | 92b175c84d | |
Ben Visness | 62972ae35a | |
Ben Visness | 09652c4146 | |
Asaf Gartner | 316aba12b6 | |
Asaf Gartner | 64f94bddbb | |
Asaf Gartner | fee5331c22 | |
Asaf Gartner | 7dbd64f62a | |
Asaf Gartner | 9d1d249ec0 | |
Ben Visness | 359354f2aa |
1
go.mod
1
go.mod
|
@ -62,6 +62,7 @@ require (
|
|||
golang.org/x/text v0.3.6 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
mvdan.cc/xurls/v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
|
|
4
go.sum
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/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
|
@ -290,6 +291,7 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
|
|||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
|
@ -526,4 +528,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
|
|||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc=
|
||||
mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
|
|
|
@ -6,7 +6,7 @@ function initCarousel(container, options = {}) {
|
|||
const buttonContainer = container.querySelector('.carousel-buttons');
|
||||
|
||||
let current = 0;
|
||||
function activateCarousel(i) {
|
||||
function activateCarousel(i, silent = false) {
|
||||
const items = document.querySelectorAll('.carousel-item');
|
||||
for (const item of items) {
|
||||
item.classList.remove('active');
|
||||
|
@ -29,8 +29,10 @@ function initCarousel(container, options = {}) {
|
|||
|
||||
current = i;
|
||||
|
||||
if (!silent) {
|
||||
onChange(current);
|
||||
}
|
||||
}
|
||||
|
||||
function activateNext() {
|
||||
activateCarousel((current + numCarouselItems + 1) % numCarouselItems);
|
||||
|
@ -67,7 +69,7 @@ function initCarousel(container, options = {}) {
|
|||
buttonContainer.appendChild(button);
|
||||
}
|
||||
|
||||
activateCarousel(0);
|
||||
activateCarousel(0, true);
|
||||
|
||||
return {
|
||||
next: activateNext,
|
||||
|
|
|
@ -8,7 +8,7 @@ const TimelineMediaTypes = {
|
|||
|
||||
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
||||
const modalTemplate = makeTemplateCloner("timeline_modal");
|
||||
const tagTemplate = makeTemplateCloner("timeline_item_tag");
|
||||
const projectLinkTemplate = makeTemplateCloner("project_link");
|
||||
|
||||
function showcaseTimestamp(rawDate) {
|
||||
const date = new Date(rawDate*1000);
|
||||
|
@ -97,14 +97,16 @@ function makeShowcaseItem(timelineItem) {
|
|||
modalEl.date.textContent = timestamp;
|
||||
modalEl.date.setAttribute("href", timelineItem.snippet_url);
|
||||
|
||||
if (timelineItem.tags.length === 0) {
|
||||
modalEl.tags.remove();
|
||||
if (timelineItem.projects.length === 0) {
|
||||
modalEl.projects.remove();
|
||||
} else {
|
||||
for (const tag of timelineItem.tags) {
|
||||
const tagItem = tagTemplate();
|
||||
tagItem.tag.innerText = tag.text;
|
||||
for (const proj of timelineItem.projects) {
|
||||
const projectLink = projectLinkTemplate();
|
||||
projectLink.root.href = proj.url;
|
||||
projectLink.logo.src = proj.logo;
|
||||
projectLink.name.textContent = proj.name;
|
||||
|
||||
modalEl.tags.appendChild(tagItem.root);
|
||||
modalEl.projects.appendChild(projectLink.root);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,385 @@
|
|||
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, stickyProjectId, 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();
|
||||
|
||||
if (originalSnippetEl) {
|
||||
snippetEdit.cancelLink.addEventListener("click", function() {
|
||||
cancel();
|
||||
});
|
||||
} else {
|
||||
snippetEdit.cancelLink.remove();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (originalSnippetEl) {
|
||||
snippetEdit.root.parentElement.insertBefore(originalSnippetEl, snippetEdit.root);
|
||||
}
|
||||
snippetEdit.root.remove();
|
||||
}
|
||||
|
||||
function addProject(proj) {
|
||||
let projEl = snippetEditProjectTemplate();
|
||||
projEl.projectId.value = proj.id;
|
||||
projEl.projectLogo.src = proj.logo;
|
||||
projEl.projectName.textContent = proj.name;
|
||||
if (proj.id == stickyProjectId) {
|
||||
projEl.removeButton.remove();
|
||||
} else {
|
||||
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.uploadResetBox.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 = "block";
|
||||
snippetEdit.previewBox.style.display = "none";
|
||||
if (originalAttachment) {
|
||||
snippetEdit.uploadResetBox.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++;
|
||||
let droppable = Array.from(ev.dataTransfer.items).some(
|
||||
item => item.kind.toLowerCase() === "file"
|
||||
);
|
||||
if (droppable) {
|
||||
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();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
snippetEdit.deleteButton.addEventListener("click", function(ev) {
|
||||
if (!window.confirm("Are you sure you want to delete this snippet?")) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
snippetEdit.file.value = "";
|
||||
});
|
||||
|
||||
validate();
|
||||
|
||||
return snippetEdit;
|
||||
}
|
||||
|
||||
function editTimelineSnippet(timelineItemEl, stickyProjectId) {
|
||||
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, stickyProjectId, timelineItemEl.getAttribute("data-id"), timelineItemEl);
|
||||
timelineItemEl.parentElement.insertBefore(snippetEdit.root, timelineItemEl);
|
||||
timelineItemEl.remove();
|
||||
}
|
|
@ -4703,15 +4703,19 @@ code, .code {
|
|||
.optionbar .options button,
|
||||
.optionbar .options .button,
|
||||
.optionbar .options input[type=button],
|
||||
.optionbar .options input[type=submit], .post-content th, .post-content td {
|
||||
.optionbar .options input[type=submit], .post-content th, .post-content td,
|
||||
button.button-small,
|
||||
.button.button-small,
|
||||
input.button-small[type=button],
|
||||
input.button-small[type=submit] {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem; }
|
||||
|
||||
.pv2, .tab-bar .tab-button,
|
||||
button,
|
||||
.button,
|
||||
input[type=button],
|
||||
input[type=submit], .notice {
|
||||
button:not(.button-small),
|
||||
.button:not(.button-small),
|
||||
input[type=button]:not(.button-small),
|
||||
input[type=submit]:not(.button-small), .notice {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem; }
|
||||
|
||||
|
@ -4747,15 +4751,19 @@ input[type=submit], .notice {
|
|||
.optionbar .options button,
|
||||
.optionbar .options .button,
|
||||
.optionbar .options input[type=button],
|
||||
.optionbar .options input[type=submit], .pagination .button, .post-content th, .post-content td {
|
||||
.optionbar .options input[type=submit], .pagination .button, .post-content th, .post-content td,
|
||||
button.button-small,
|
||||
.button.button-small,
|
||||
input.button-small[type=button],
|
||||
input.button-small[type=submit] {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem; }
|
||||
|
||||
.ph3, .tab-bar .tab-button,
|
||||
button,
|
||||
.button,
|
||||
input[type=button],
|
||||
input[type=submit], .notice {
|
||||
button:not(.button-small),
|
||||
.button:not(.button-small),
|
||||
input[type=button]:not(.button-small),
|
||||
input[type=submit]:not(.button-small), .notice {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem; }
|
||||
|
||||
|
@ -7461,6 +7469,9 @@ article code {
|
|||
.minh-6 {
|
||||
min-height: 32rem; }
|
||||
|
||||
.h1-5 {
|
||||
height: 1.5rem; }
|
||||
|
||||
.fira {
|
||||
font-family: "Fira Sans", sans-serif; }
|
||||
|
||||
|
@ -7515,6 +7526,9 @@ article code {
|
|||
.g5 {
|
||||
gap: 4rem; }
|
||||
|
||||
.hide-if-empty:empty {
|
||||
display: none !important; }
|
||||
|
||||
@media screen and (min-width: 30em) {
|
||||
.bi-avoid-ns {
|
||||
break-inside: avoid; }
|
||||
|
@ -8515,6 +8529,7 @@ input[type=submit] {
|
|||
flex-shrink: 0;
|
||||
border-radius: 100%;
|
||||
overflow: hidden;
|
||||
object-fit: cover;
|
||||
background-color: #bbb;
|
||||
background-color: var(--dimmest-color); }
|
||||
|
||||
|
@ -9378,6 +9393,9 @@ span.icon-rss::before {
|
|||
.timeline-item .avatar-icon.big {
|
||||
width: 3.875rem;
|
||||
height: 3.875rem; } }
|
||||
.timeline-item .timeline-content-box {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--timeline-content-background); }
|
||||
.timeline-item .timeline-content-box > * {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
|
|
@ -288,6 +288,7 @@ pre, code, .codeblock {
|
|||
--forum-diff-insert-border-color: #30591b;
|
||||
--card-background: #282828;
|
||||
--card-background-hover: #333;
|
||||
--timeline-content-background: rgba(255, 255, 255, 0.06);
|
||||
--irc-border-color: #333;
|
||||
--irc-tab-current-shadow: 0px 0px 5px #000 inset;
|
||||
--irc-tab-close-button-color: #bbb;
|
||||
|
|
|
@ -306,6 +306,7 @@ pre, code, .codeblock {
|
|||
--forum-diff-insert-border-color: #5baa3f;
|
||||
--card-background: #e8e8e8;
|
||||
--card-background-hover: #f0f0f0;
|
||||
--timeline-content-background: rgba(0, 0, 0, 0.2);
|
||||
--irc-border-color: #ddd;
|
||||
--irc-tab-current-shadow: 0px 0px 5px #bbb inset;
|
||||
--irc-tab-close-button-color: #fff;
|
||||
|
|
|
@ -466,5 +466,48 @@ func init() {
|
|||
moveThreadsToSubforumCommand.MarkFlagRequired("subforum_slug")
|
||||
adminCommand.AddCommand(moveThreadsToSubforumCommand)
|
||||
|
||||
fixupSnippetAssociation := &cobra.Command{
|
||||
Use: "fixupsnippets",
|
||||
Short: "Associates tagged snippets with the right projects",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
|
||||
type snippetProject struct {
|
||||
SnippetID int `db:"snippet_tag.snippet_id"`
|
||||
ProjectID int `db:"project.id"`
|
||||
}
|
||||
res, err := db.Query[snippetProject](ctx, conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM snippet_tag
|
||||
JOIN project ON project.tag = snippet_tag.tag_id
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, sp := range res {
|
||||
_, err = conn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet_project (snippet_id, project_id, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
`,
|
||||
sp.SnippetID,
|
||||
sp.ProjectID,
|
||||
models.SnippetProjectKindDiscord,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Done!\n")
|
||||
},
|
||||
}
|
||||
adminCommand.AddCommand(fixupSnippetAssociation)
|
||||
|
||||
addProjectCommands(adminCommand)
|
||||
}
|
||||
|
|
|
@ -80,7 +80,6 @@ type DiscordConfig struct {
|
|||
ShowcaseChannelID string
|
||||
LibraryChannelID string
|
||||
StreamsChannelID string
|
||||
JamShowcaseChannelID string
|
||||
}
|
||||
|
||||
type TwitchConfig struct {
|
||||
|
|
|
@ -783,9 +783,6 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
// Match only tags for projects in which the current user is a collaborator.
|
||||
messageTags := getDiscordTags(existingSnippet.Description)
|
||||
|
||||
var desiredTags []int
|
||||
var allTags []int
|
||||
|
||||
// Fetch projects so we know what tags the user can apply to their snippet.
|
||||
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{interned.HMNUser.ID},
|
||||
|
@ -794,61 +791,40 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
return oops.New(err, "failed to look up user projects")
|
||||
}
|
||||
|
||||
projectIDs := make([]int, len(projects))
|
||||
for i, p := range projects {
|
||||
projectIDs[i] = p.Project.ID
|
||||
}
|
||||
|
||||
userTags, err := db.Query[models.Tag](ctx, tx,
|
||||
`
|
||||
SELECT $columns{tag}
|
||||
FROM
|
||||
tag
|
||||
JOIN project ON project.tag = tag.id
|
||||
WHERE
|
||||
project.id = ANY ($1)
|
||||
`,
|
||||
projectIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch tags for user projects")
|
||||
}
|
||||
|
||||
for _, tag := range userTags {
|
||||
allTags = append(allTags, tag.ID)
|
||||
for _, messageTag := range messageTags {
|
||||
if strings.EqualFold(tag.Text, messageTag) {
|
||||
desiredTags = append(desiredTags, tag.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM snippet_tag
|
||||
DELETE FROM snippet_project
|
||||
WHERE
|
||||
snippet_id = $1
|
||||
AND tag_id = ANY ($2)
|
||||
AND kind = $2
|
||||
`,
|
||||
existingSnippet.ID,
|
||||
allTags,
|
||||
models.SnippetProjectKindDiscord,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to clear tags from snippet")
|
||||
return oops.New(err, "failed to clear project association for snippet")
|
||||
}
|
||||
|
||||
for _, tagID := range desiredTags {
|
||||
for _, p := range projects {
|
||||
if p.Tag != nil {
|
||||
for _, messageTag := range messageTags {
|
||||
if strings.EqualFold(p.Tag.Text, messageTag) {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet_tag (snippet_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
INSERT INTO snippet_project (project_id, snippet_id, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
p.Project.ID,
|
||||
existingSnippet.ID,
|
||||
tagID,
|
||||
models.SnippetProjectKindDiscord,
|
||||
)
|
||||
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
|
||||
// do we actually want to reuse those, or should we keep them separate?
|
||||
// TODO(asaf): Centralize this
|
||||
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
|
||||
|
||||
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
package embed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
)
|
||||
|
||||
var DownloadTooBigError = errors.New("download too big")
|
||||
var NoEmbedFound = errors.New("no embed found")
|
||||
|
||||
type Embeddable struct {
|
||||
Url string
|
||||
File *Embed
|
||||
}
|
||||
|
||||
type Embed struct {
|
||||
Data []byte
|
||||
ContentType string
|
||||
Filename string
|
||||
}
|
||||
|
||||
var EmbeddableUrlRegex = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
|
||||
|
||||
func IsUrlEmbeddable(u string) bool {
|
||||
return EmbeddableUrlRegex.MatchString(u)
|
||||
}
|
||||
|
||||
func GetEmbeddableFromUrls(ctx context.Context, urls []string, maxSize int, httpTimeout time.Duration, httpMaxAttempts int) (*Embeddable, error) {
|
||||
embedError := NoEmbedFound
|
||||
client := &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
}
|
||||
for _, urlStr := range urls {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
urlStr = u.String()
|
||||
}
|
||||
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if IsUrlEmbeddable(urlStr) {
|
||||
result := Embeddable{
|
||||
Url: urlStr,
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
if httpMaxAttempts > 0 {
|
||||
httpMaxAttempts -= 1
|
||||
embed, err := FetchEmbed(ctx, urlStr, client, maxSize)
|
||||
if err != nil {
|
||||
embedError = err
|
||||
continue
|
||||
}
|
||||
result := Embeddable{
|
||||
File: embed,
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
return nil, embedError
|
||||
}
|
||||
|
||||
// If the url points to a file, downloads and returns the file.
|
||||
// If the url points to an html page, parses opengraph and tries to fetch an image/video/audio file according to that.
|
||||
// maxSize only limits the actual filesize. In the case of html we always fetch up to 100kb even if maxSize is smaller.
|
||||
func FetchEmbed(ctx context.Context, urlStr string, httpClient *http.Client, maxSize int) (*Embed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
return nil, NoEmbedFound
|
||||
}
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "text/html") || strings.HasPrefix(contentType, "application/html") {
|
||||
var buffer bytes.Buffer
|
||||
_, err := io.CopyN(&buffer, res.Body, 100*1024) // NOTE(asaf): If the opengraph stuff isn't in the first 100kb, we don't care.
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
partialHtml := buffer.Bytes()
|
||||
urlStr = ExtractEmbedFromOpenGraph(partialHtml)
|
||||
if urlStr == "" {
|
||||
return nil, NoEmbedFound
|
||||
}
|
||||
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err = httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
return nil, NoEmbedFound
|
||||
}
|
||||
contentType = res.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
n, err := io.CopyN(&buffer, res.Body, int64(maxSize+1))
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filename := ""
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
lastSlash := utils.IntMax(strings.LastIndex(u.Path, "/"), 0)
|
||||
filename = u.Path[lastSlash:]
|
||||
}
|
||||
result := Embed{
|
||||
Data: buffer.Bytes(),
|
||||
ContentType: contentType,
|
||||
Filename: filename,
|
||||
}
|
||||
if n == int64(maxSize+1) {
|
||||
err = DownloadTooBigError
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
|
||||
var metaRegex = regexp.MustCompile(`<meta\s+([^>]+)/?>`)
|
||||
var metaAttrRegex = regexp.MustCompile(`(?P<key>\w+)="(?P<value>[^"]+)"`)
|
||||
|
||||
var OGKeys = []string{
|
||||
"og:audio",
|
||||
"og:video",
|
||||
"og:image",
|
||||
"og:audio:url",
|
||||
"og:image:url",
|
||||
"og:video:url",
|
||||
"og:audio:secure_url",
|
||||
"og:image:secure_url",
|
||||
"og:video:secure_url",
|
||||
"twitter:image",
|
||||
}
|
||||
|
||||
// Tries to find an opengraph image/video/audio url in the provided html
|
||||
// Since we only need to look at meta tags in the head, we don't need the full html document.
|
||||
func ExtractEmbedFromOpenGraph(partialHtml []byte) string {
|
||||
keyIdx := metaAttrRegex.SubexpIndex("key")
|
||||
valueIdx := metaAttrRegex.SubexpIndex("value")
|
||||
html := string(partialHtml)
|
||||
metaTags := metaRegex.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range metaTags {
|
||||
content := ""
|
||||
relevantProp := false
|
||||
attrs := metaAttrRegex.FindAllStringSubmatch(m[1], -1)
|
||||
for _, attr := range attrs {
|
||||
key := attr[keyIdx]
|
||||
value := attr[valueIdx]
|
||||
if key == "name" || key == "property" {
|
||||
for _, ogKey := range OGKeys {
|
||||
if value == ogKey {
|
||||
relevantProp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if key == "content" {
|
||||
content = value
|
||||
}
|
||||
}
|
||||
if content != "" && relevantProp {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package hmndata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
)
|
||||
|
||||
type Jam struct {
|
||||
Name string
|
||||
Slug string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
}
|
||||
|
||||
var WRJ2021 = Jam{
|
||||
Name: "Wheel Reinvention Jam 2021",
|
||||
Slug: "WRJ2021",
|
||||
StartTime: time.Date(2021, 9, 27, 0, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2021, 10, 4, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
var WRJ2022 = Jam{
|
||||
Name: "Wheel Reinvention Jam 2022",
|
||||
Slug: "WRJ2022",
|
||||
StartTime: time.Date(2022, 8, 15, 8, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
EndTime: time.Date(2022, 8, 22, 8, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
}
|
||||
|
||||
var AllJams = []Jam{WRJ2021, WRJ2022}
|
||||
|
||||
func CurrentJam() *Jam {
|
||||
now := time.Now()
|
||||
for i, jam := range AllJams {
|
||||
if jam.StartTime.Before(now) && now.Before(jam.EndTime) {
|
||||
return &AllJams[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func JamBySlug(slug string) Jam {
|
||||
for _, jam := range AllJams {
|
||||
if jam.Slug == slug {
|
||||
return jam
|
||||
}
|
||||
}
|
||||
return Jam{Slug: slug}
|
||||
}
|
||||
|
||||
func FetchJamsForProject(ctx context.Context, dbConn db.ConnOrTx, user *models.User, projectId int) ([]*models.JamProject, error) {
|
||||
jamProjects, err := db.Query[models.JamProject](ctx, dbConn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM jam_project
|
||||
WHERE project_id = $1
|
||||
`,
|
||||
projectId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch jams for project")
|
||||
}
|
||||
|
||||
currentJam := CurrentJam()
|
||||
foundCurrent := false
|
||||
for i, _ := range jamProjects {
|
||||
jam := JamBySlug(jamProjects[i].JamSlug)
|
||||
jamProjects[i].JamName = jam.Name
|
||||
jamProjects[i].JamStartTime = jam.StartTime
|
||||
|
||||
if currentJam != nil && currentJam.Slug == jamProjects[i].JamSlug {
|
||||
foundCurrent = true
|
||||
}
|
||||
}
|
||||
if currentJam != nil && !foundCurrent {
|
||||
jamProjects = append(jamProjects, &models.JamProject{
|
||||
ProjectID: projectId,
|
||||
JamSlug: currentJam.Slug,
|
||||
Participating: false,
|
||||
JamName: currentJam.Name,
|
||||
JamStartTime: currentJam.StartTime,
|
||||
})
|
||||
}
|
||||
|
||||
if user != nil && user.IsStaff {
|
||||
for _, jam := range AllJams {
|
||||
found := false
|
||||
for _, jp := range jamProjects {
|
||||
if jp.JamSlug == jam.Slug {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
jamProjects = append(jamProjects, &models.JamProject{
|
||||
ProjectID: projectId,
|
||||
JamSlug: jam.Slug,
|
||||
Participating: false,
|
||||
JamName: jam.Name,
|
||||
JamStartTime: jam.StartTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(jamProjects, func(i, j int) bool {
|
||||
return jamProjects[i].JamStartTime.Before(jamProjects[j].JamStartTime)
|
||||
})
|
||||
|
||||
return jamProjects, nil
|
||||
}
|
|
@ -29,6 +29,7 @@ type ProjectsQuery struct {
|
|||
ProjectIDs []int // if empty, all projects
|
||||
Slugs []string // if empty, all projects
|
||||
OwnerIDs []int // if empty, all projects
|
||||
JamSlugs []string // if empty, all projects
|
||||
|
||||
// Ignored when using CountProjects
|
||||
Limit, Offset int // if empty, no pagination
|
||||
|
@ -101,6 +102,14 @@ func FetchProjects(
|
|||
)
|
||||
}
|
||||
|
||||
if len(q.JamSlugs) > 0 {
|
||||
qb.Add(
|
||||
`
|
||||
JOIN jam_project ON jam_project.project_id = project.id
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
// Filters (permissions are checked after the query, in Go)
|
||||
qb.Add(`
|
||||
WHERE
|
||||
|
@ -130,6 +139,9 @@ func FetchProjects(
|
|||
if len(q.Slugs) > 0 {
|
||||
qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
|
||||
}
|
||||
if len(q.JamSlugs) > 0 {
|
||||
qb.Add(`AND (jam_project.jam_slug = ANY ($?) AND jam_project.participating = TRUE)`, q.JamSlugs)
|
||||
}
|
||||
|
||||
// Output
|
||||
if q.Limit > 0 {
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
type SnippetQuery struct {
|
||||
IDs []int
|
||||
OwnerIDs []int
|
||||
ProjectIDs []int
|
||||
Tags []int
|
||||
DiscordMessageIDs []string
|
||||
|
||||
|
@ -24,6 +25,7 @@ type SnippetAndStuff struct {
|
|||
Asset *models.Asset `db:"asset"`
|
||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
Tags []*models.Tag
|
||||
Projects []*ProjectAndStuff
|
||||
}
|
||||
|
||||
func FetchSnippets(
|
||||
|
@ -42,6 +44,7 @@ func FetchSnippets(
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var tagSnippetIDs []int
|
||||
if len(q.Tags) > 0 {
|
||||
// Get snippet IDs with this tag, then use that in the main query
|
||||
snippetIDs, err := db.QueryScalar[int](ctx, tx,
|
||||
|
@ -64,7 +67,32 @@ func FetchSnippets(
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
q.IDs = snippetIDs
|
||||
tagSnippetIDs = snippetIDs
|
||||
}
|
||||
|
||||
var projectSnippetIDs []int
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
// Get snippet IDs for these projects, then use that in the main query
|
||||
snippetIDs, err := db.QueryScalar[int](ctx, tx,
|
||||
`
|
||||
SELECT DISTINCT snippet_id
|
||||
FROM
|
||||
snippet_project
|
||||
WHERE
|
||||
project_id = ANY ($1)
|
||||
`,
|
||||
q.ProjectIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
||||
}
|
||||
|
||||
// special early-out: no snippets found for these projects at all
|
||||
if len(snippetIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
projectSnippetIDs = snippetIDs
|
||||
}
|
||||
|
||||
var qb db.QueryBuilder
|
||||
|
@ -74,7 +102,7 @@ func FetchSnippets(
|
|||
FROM
|
||||
snippet
|
||||
LEFT JOIN hmn_user AS owner ON snippet.owner_id = owner.id
|
||||
LEFT JOIN asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id
|
||||
LEFT JOIN asset AS avatar ON avatar.id = owner.avatar_asset_id
|
||||
LEFT JOIN asset ON snippet.asset_id = asset.id
|
||||
LEFT JOIN discord_message ON snippet.discord_message_id = discord_message.id
|
||||
WHERE
|
||||
|
@ -84,6 +112,12 @@ func FetchSnippets(
|
|||
if len(q.IDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
|
||||
}
|
||||
if len(tagSnippetIDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, tagSnippetIDs)
|
||||
}
|
||||
if len(projectSnippetIDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, projectSnippetIDs)
|
||||
}
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||
}
|
||||
|
@ -115,6 +149,7 @@ func FetchSnippets(
|
|||
type resultRow struct {
|
||||
Snippet models.Snippet `db:"snippet"`
|
||||
Owner *models.User `db:"owner"`
|
||||
AvatarAsset *models.Asset `db:"avatar"`
|
||||
Asset *models.Asset `db:"asset"`
|
||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
}
|
||||
|
@ -127,6 +162,9 @@ func FetchSnippets(
|
|||
result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
|
||||
snippetIDs := make([]int, len(results))
|
||||
for i, row := range results {
|
||||
if results[i].Owner != nil {
|
||||
results[i].Owner.AvatarAsset = results[i].AvatarAsset
|
||||
}
|
||||
result[i] = SnippetAndStuff{
|
||||
Snippet: row.Snippet,
|
||||
Owner: row.Owner,
|
||||
|
@ -167,6 +205,42 @@ func FetchSnippets(
|
|||
item.Tags = append(item.Tags, snippetTag.Tag)
|
||||
}
|
||||
|
||||
// Fetch projects
|
||||
type snippetProjectRow struct {
|
||||
SnippetID int `db:"snippet_id"`
|
||||
ProjectID int `db:"project_id"`
|
||||
}
|
||||
snippetProjects, err := db.Query[snippetProjectRow](ctx, tx,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM snippet_project
|
||||
WHERE snippet_id = ANY($1)
|
||||
`,
|
||||
snippetIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch project ids for snippets")
|
||||
}
|
||||
var projectIds []int
|
||||
for _, sp := range snippetProjects {
|
||||
projectIds = append(projectIds, sp.ProjectID)
|
||||
}
|
||||
projects, err := FetchProjects(ctx, tx, currentUser, ProjectsQuery{ProjectIDs: projectIds})
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch projects for snippets")
|
||||
}
|
||||
projectMap := make(map[int]*ProjectAndStuff)
|
||||
for i := range projects {
|
||||
projectMap[projects[i].Project.ID] = &projects[i]
|
||||
}
|
||||
for _, sp := range snippetProjects {
|
||||
snip, hasResult := resultBySnippetId[sp.SnippetID]
|
||||
proj, hasProj := projectMap[sp.ProjectID]
|
||||
if hasResult && hasProj {
|
||||
snip.Projects = append(snip.Projects, proj)
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to commit transaction")
|
||||
|
|
|
@ -120,6 +120,10 @@ func TestSnippet(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||
}
|
||||
|
||||
func TestSnippetSubmit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildSnippetSubmit(), RegexSnippetSubmit, nil)
|
||||
}
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildFeed(), RegexFeed, nil)
|
||||
assert.Equal(t, BuildFeed(), BuildFeedWithPage(1))
|
||||
|
|
|
@ -56,7 +56,26 @@ func BuildJamIndex() string {
|
|||
return Url("/jam", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2021 = regexp.MustCompile("^/jam/2021")
|
||||
var RegexJamIndex2021 = regexp.MustCompile("^/jam/2021$")
|
||||
|
||||
func BuildJamIndex2021() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2021", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2022 = regexp.MustCompile("^/jam/2022$")
|
||||
|
||||
func BuildJamIndex2022() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2022", nil)
|
||||
}
|
||||
|
||||
var RegexJamFeed2022 = regexp.MustCompile("^/jam/2022/feed$")
|
||||
|
||||
func BuildJamFeed2022() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2022/feed", nil)
|
||||
}
|
||||
|
||||
// QUESTION(ben): Can we change these routes?
|
||||
|
||||
|
@ -243,6 +262,13 @@ func BuildSnippet(snippetId int) string {
|
|||
return Url("/snippet/"+strconv.Itoa(snippetId), nil)
|
||||
}
|
||||
|
||||
var RegexSnippetSubmit = regexp.MustCompile(`^/snippet$`)
|
||||
|
||||
func BuildSnippetSubmit() string {
|
||||
defer CatchPanic()
|
||||
return Url("/snippet", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Feed
|
||||
*/
|
||||
|
@ -308,6 +334,12 @@ func BuildProjectNew() string {
|
|||
return Url("/p/new", nil)
|
||||
}
|
||||
|
||||
func BuildProjectNewJam() string {
|
||||
defer CatchPanic()
|
||||
|
||||
return Url("/p/new", []Q{Q{Name: "jam", Value: "1"}})
|
||||
}
|
||||
|
||||
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<projectslug>[a-zA-Z0-9-]+))?")
|
||||
|
||||
func BuildPersonalProject(id int, slug string) string {
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddJamProjects{})
|
||||
}
|
||||
|
||||
type AddJamProjects struct{}
|
||||
|
||||
func (m AddJamProjects) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2022, 6, 18, 1, 3, 39, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddJamProjects) Name() string {
|
||||
return "AddJamProjects"
|
||||
}
|
||||
|
||||
func (m AddJamProjects) Description() string {
|
||||
return "Add jam and project association table"
|
||||
}
|
||||
|
||||
func (m AddJamProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
CREATE TABLE jam_project (
|
||||
project_id INT REFERENCES project (id) ON DELETE CASCADE,
|
||||
jam_slug VARCHAR(64) NOT NULL,
|
||||
participating BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (project_id, jam_slug)
|
||||
);
|
||||
CREATE INDEX jam_project_jam_slug ON jam_project (jam_slug);
|
||||
CREATE INDEX jam_project_project_id ON jam_project (project_id);
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m AddJamProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
DROP INDEX jam_project_jam_slug;
|
||||
DROP INDEX jam_project_project_id;
|
||||
DROP TABLE jam_project;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type JamProject struct {
|
||||
ProjectID int `db:"project_id"`
|
||||
JamSlug string `db:"jam_slug"`
|
||||
Participating bool `db:"participating"`
|
||||
JamName string
|
||||
JamStartTime time.Time
|
||||
}
|
|
@ -6,6 +6,13 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SnippetProjectAssociationKind int
|
||||
|
||||
const (
|
||||
SnippetProjectKindDiscord SnippetProjectAssociationKind = iota + 1
|
||||
SnippetProjectKindWebsite
|
||||
)
|
||||
|
||||
type Snippet struct {
|
||||
ID int `db:"id"`
|
||||
OwnerID int `db:"owner_id"`
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting"
|
||||
|
@ -42,6 +43,7 @@ var DiscordMarkdown = makeGoldmark(
|
|||
Previews: false,
|
||||
Embeds: false,
|
||||
})...),
|
||||
goldmark.WithRendererOptions(html.WithHardWraps()),
|
||||
)
|
||||
|
||||
func ParseMarkdown(source string, md goldmark.Markdown) string {
|
||||
|
|
|
@ -333,6 +333,10 @@ article code {
|
|||
min-height: $height-6;
|
||||
}
|
||||
|
||||
.h1-5 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.fira {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
}
|
||||
|
@ -356,6 +360,10 @@ article code {
|
|||
.g4 { gap: $spacing-large; }
|
||||
.g5 { gap: $spacing-extra-large; }
|
||||
|
||||
.hide-if-empty:empty {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media #{$breakpoint-not-small} {
|
||||
.bi-avoid-ns { break-inside: avoid; }
|
||||
.cc-auto-ns { column-count: auto; }
|
||||
|
|
|
@ -139,8 +139,15 @@ input, select, textarea {
|
|||
}
|
||||
|
||||
#{$buttons} {
|
||||
&:not(.button-small) {
|
||||
@extend .ph3;
|
||||
@extend .pv2;
|
||||
}
|
||||
|
||||
&.button-small {
|
||||
@extend .ph2;
|
||||
@extend .pv1;
|
||||
}
|
||||
|
||||
@include usevar('color', 'form-button-color');
|
||||
@include usevar('background-color', 'form-button-background');
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
flex-shrink: 0;
|
||||
border-radius: 100%;
|
||||
overflow: hidden;
|
||||
object-fit: cover;
|
||||
@include usevar(background-color, dimmest-color);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,19 +18,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
.timeline-content-box > * {
|
||||
.timeline-content-box {
|
||||
@include usevar(background-color, timeline-content-background);
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.timeline-content-box.embed {
|
||||
&.embed {
|
||||
@extend .aspect-ratio, .aspect-ratio--16x9;
|
||||
|
||||
> iframe {
|
||||
@extend .aspect-ratio--object;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-modal {
|
||||
|
|
|
@ -3,7 +3,7 @@ $fg-font-color: #eee;
|
|||
$vars: (
|
||||
fg-font-color: $fg-font-color,
|
||||
|
||||
// Default theme colors in case the assets.css is busted
|
||||
// Default theme colors in case the project.css is busted
|
||||
theme-color: #666,
|
||||
theme-color-dim: #444,
|
||||
theme-color-dimmer: #383838,
|
||||
|
@ -99,6 +99,8 @@ $vars: (
|
|||
card-background: #282828,
|
||||
card-background-hover: #333,
|
||||
|
||||
timeline-content-background: rgba(255, 255, 255, 0.06),
|
||||
|
||||
irc-border-color: #333,
|
||||
irc-tab-current-shadow: 0px 0px 5px #000 inset,
|
||||
irc-tab-close-button-color: #bbb,
|
||||
|
|
|
@ -99,6 +99,8 @@ $vars: (
|
|||
card-background: #e8e8e8,
|
||||
card-background-hover: #f0f0f0,
|
||||
|
||||
timeline-content-background: rgba(0, 0, 0, 0.2),
|
||||
|
||||
irc-border-color: #ddd,
|
||||
irc-tab-current-shadow: 0px 0px 5px #bbb inset,
|
||||
irc-tab-close-button-color: #fff,
|
||||
|
|
|
@ -78,6 +78,7 @@ func ProjectToTemplate(
|
|||
url string,
|
||||
) Project {
|
||||
return Project{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Subdomain: p.Subdomain(),
|
||||
Color1: p.Color1,
|
||||
|
@ -297,8 +298,6 @@ func LinkToTemplate(link *models.Link) Link {
|
|||
return tlink
|
||||
}
|
||||
|
||||
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
|
||||
|
||||
func TimelineItemsToJSON(items []TimelineItem) string {
|
||||
// NOTE(asaf): As of 2021-06-22: This only serializes the data necessary for snippet showcase.
|
||||
builder := strings.Builder{}
|
||||
|
@ -378,22 +377,26 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
|||
builder.WriteString(item.DiscordMessageUrl)
|
||||
builder.WriteString(`",`)
|
||||
|
||||
builder.WriteString(`"tags":[`)
|
||||
for i, tag := range item.Tags {
|
||||
builder.WriteString(`{`)
|
||||
builder.WriteString(`"projects":[`)
|
||||
for j, proj := range item.Projects {
|
||||
if j > 0 {
|
||||
builder.WriteRune(',')
|
||||
}
|
||||
builder.WriteRune('{')
|
||||
|
||||
builder.WriteString(`"text":"`)
|
||||
builder.WriteString(tag.Text)
|
||||
builder.WriteString(`"name":"`)
|
||||
builder.WriteString(proj.Name)
|
||||
builder.WriteString(`",`)
|
||||
|
||||
builder.WriteString(`"logo":"`)
|
||||
builder.WriteString(proj.Logo)
|
||||
builder.WriteString(`",`)
|
||||
|
||||
builder.WriteString(`"url":"`)
|
||||
builder.WriteString(tag.Url)
|
||||
builder.WriteString(proj.Url)
|
||||
builder.WriteString(`"`)
|
||||
|
||||
builder.WriteString(`}`)
|
||||
if i < len(item.Tags)-1 {
|
||||
builder.WriteString(`,`)
|
||||
}
|
||||
builder.WriteRune('}')
|
||||
}
|
||||
builder.WriteString(`]`)
|
||||
|
||||
|
@ -403,6 +406,33 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
|||
return builder.String()
|
||||
}
|
||||
|
||||
func SnippetEditProjectsToJSON(projects []Project) string {
|
||||
builder := strings.Builder{}
|
||||
builder.WriteRune('[')
|
||||
for i, proj := range projects {
|
||||
if i > 0 {
|
||||
builder.WriteRune(',')
|
||||
}
|
||||
builder.WriteRune('{')
|
||||
|
||||
builder.WriteString(`"id":`)
|
||||
builder.WriteString(strconv.FormatInt(int64(proj.ID), 10))
|
||||
builder.WriteRune(',')
|
||||
|
||||
builder.WriteString(`"name":"`)
|
||||
builder.WriteString(proj.Name)
|
||||
builder.WriteString(`",`)
|
||||
|
||||
builder.WriteString(`"logo":"`)
|
||||
builder.WriteString(proj.Logo)
|
||||
builder.WriteRune('"')
|
||||
|
||||
builder.WriteRune('}')
|
||||
}
|
||||
builder.WriteRune(']')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func PodcastToTemplate(podcast *models.Podcast, imageFilename string) Podcast {
|
||||
imageUrl := ""
|
||||
if imageFilename != "" {
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "extrahead" }}
|
||||
<style>
|
||||
.timeline-item {
|
||||
background: rgba(147, 147, 147, 0.15);
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ range .UnapprovedUsers }}
|
||||
|
@ -75,28 +83,8 @@
|
|||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ range .Posts }}
|
||||
<div class="post background-even pa3">
|
||||
<div class="fl w-100 pv3 pa3-l">
|
||||
<div class="w-100 flex-l flex-row-reverse-l">
|
||||
<div class="inline-flex flex-row-reverse pl3-l pb3 items-center">
|
||||
<div class="postid">
|
||||
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 pb3">
|
||||
<div class="b" role="heading" aria-level="2">{{ .Title }}</div>
|
||||
{{ timehtml (relativedate .PostDate) .PostDate }}
|
||||
{{ if and $.User.IsStaff .IP }}
|
||||
<span>[{{ .IP }}]</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content overflow-x-auto">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ range .Timeline }}
|
||||
{{ template "timeline_item.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
<content type="xhtml">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">
|
||||
<div>
|
||||
{{ .Description }}
|
||||
{{ cleancontrolchars .Description }}
|
||||
</div>
|
||||
{{ range .EmbedMedia }}
|
||||
<div>
|
||||
|
|
|
@ -27,10 +27,10 @@
|
|||
<a class="user" data-tmpl="userLink"></a>
|
||||
<a data-tmpl="date" class="datetime tr" style="flex: 1 1 auto;"></a>
|
||||
</div>
|
||||
<div class="pre overflow-auto" data-tmpl="description">
|
||||
<div class="overflow-auto" data-tmpl="description">
|
||||
Unknown description
|
||||
</div>
|
||||
<div data-tmpl="tags" class="pt2 flex"></div>
|
||||
<div data-tmpl="projects" class="pt2 flex g2"></div>
|
||||
<div class="i f7 pt2">
|
||||
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
||||
</div>
|
||||
|
@ -40,6 +40,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template id="timeline_item_tag">
|
||||
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
|
||||
<template id="project_link">
|
||||
<a data-tmpl="root" class="snippet-project 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>
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
<style>
|
||||
.upload-box {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.snippet-edit-root.drop .upload-box {
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template id="snippet-edit">
|
||||
<form data-tmpl="root" class="snippet-edit-root 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>
|
||||
<a data-tmpl="cancelLink" href="javascript:;" title="Cancel" class="ml2 flex-shrink-0">✖</a>
|
||||
</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">
|
||||
<a data-tmpl="uploadLink" class="upload-box flex flex-column items-center pa4 b--dimmest br3 mw6" href="javascript:;">
|
||||
Upload image, video, or other file
|
||||
</a>
|
||||
<div data-tmpl="uploadResetBox" class="mt2 dn">
|
||||
<a data-tmpl="uploadResetLink" class="button button-small" href="javascript:;">Restore</a>
|
||||
</div>
|
||||
</div>
|
||||
<div data-tmpl="previewBox" class="preview dn">
|
||||
<div data-tmpl="previewContent">
|
||||
</div>
|
||||
<div class="actions mt2">
|
||||
<a data-tmpl="removeLink" class="button button-small" href="javascript:;">Remove</a>
|
||||
<a data-tmpl="resetLink" class="button button-small" href="javascript:;">Restore</a>
|
||||
<a data-tmpl="replaceLink" class="button button-small" href="javascript:;">Replace</a>
|
||||
</div>
|
||||
<div data-tmpl="errors" class="mt2 hide-if-empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div data-tmpl="projectList" class="flex-grow-1 flex flex-wrap g2"></div>
|
||||
<div class="flex-shrink-0 flex">
|
||||
<input data-tmpl="deleteButton" class="flex-grow-0 flex-shrink-0 mr2" type="submit" name="action" value="Delete" />
|
||||
<input data-tmpl="saveButton" class="flex-grow-0 flex-shrink-0" type="submit" name="action" value="Save" />
|
||||
</div>
|
||||
</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="ml2 mr1" 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 */}}
|
||||
<div class="flex items-center">
|
||||
{{ if .OwnerAvatarUrl }}
|
||||
|
@ -28,13 +28,17 @@
|
|||
{{ if .SmallInfo }}
|
||||
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
|
||||
{{ end }}
|
||||
{{ if .Editable }}
|
||||
<a href="javascript:;" class="edit ml2">✎</a>
|
||||
<div class="dn rawdesc">{{ .RawDescription }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{/* content */}}
|
||||
|
||||
{{ if .Description }}
|
||||
<div class="mt3 overflow-hidden relative {{ if .TruncateDescription }}mh-5{{ end }}">
|
||||
<div class="post-content">{{ .Description }}</div>
|
||||
<div class="post-content">{{ trim .Description }}</div>
|
||||
{{ if .TruncateDescription }}
|
||||
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
|
||||
{{ end }}
|
||||
|
@ -47,7 +51,7 @@
|
|||
{{ end }}
|
||||
|
||||
{{ range .EmbedMedia }}
|
||||
<div class="timeline-content-box mt3 {{ if eq .Type mediaembed }}embed{{ end }}">
|
||||
<div class="timeline-content-box mt3 {{ if eq .Type mediaembed }}embed{{ end }} br2 overflow-hidden flex justify-center">
|
||||
{{ if eq .Type mediaimage }}
|
||||
<img src="{{ .AssetUrl }}">
|
||||
{{ else if eq .Type mediavideo }}
|
||||
|
@ -64,12 +68,13 @@
|
|||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ with .Tags }}
|
||||
<div class="mt3 flex">
|
||||
{{ range $i, $tag := . }}
|
||||
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
||||
{{ $tag.Text }}
|
||||
</div>
|
||||
{{ with .Projects }}
|
||||
<div class="mt3 flex g2 projects">
|
||||
{{ range $i, $proj := . }}
|
||||
<a data-projid="{{ $proj.ID }}" href="{{ $proj.Url }}" class="snippet-project flex flex-row items-center bg-theme-dimmer ph2 pv1 br2">
|
||||
<img src="{{ $proj.Logo }}" class="db mr1 br1 h1-5" />
|
||||
<div>{{ $proj.Name }}</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
|
@ -146,7 +146,20 @@
|
|||
<img class="h3" src="{{ static "wheeljam2022/logo.svg" }}">
|
||||
<div id="jam-title-container" class="flex flex-column pl3-m pl4-l pv3 pv0-ns">
|
||||
<h3 id="jam-title">Wheel Reinvention Jam</h3>
|
||||
<div id="jam-details">August 15 - 21. Change the status quo.</div>
|
||||
<div id="jam-details">
|
||||
August 15 - 21.
|
||||
{{ if gt .JamDaysUntilEnd 0 }}
|
||||
{{ if eq .JamDaysUntilStart 0 }}
|
||||
<b>Happening now.</b>
|
||||
{{ else if eq .JamDaysUntilStart 1 }}
|
||||
<b>Starting tomorrow.</b>
|
||||
{{ else }}
|
||||
<b>In {{ .JamDaysUntilStart }} days.</b>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<b>See the results.</b>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div id="jam-learn-more">
|
||||
|
|
|
@ -0,0 +1,355 @@
|
|||
{{/*
|
||||
This is a copy-paste from base.html because we want to preserve the unique
|
||||
style of this page no matter what future changes we make to the base.
|
||||
*/}}
|
||||
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ static "wheeljam2022/favicon-16x16.png" }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ static "wheeljam2022/favicon-32x32.png" }}">
|
||||
|
||||
{{ if .CanonicalLink }}<link rel="canonical" href="{{ .CanonicalLink }}">{{ end }}
|
||||
{{ range .OpenGraphItems }}
|
||||
{{ if .Property }}
|
||||
<meta property="{{ .Property }}" content="{{ .Value }}" />
|
||||
{{ else }}
|
||||
<meta name="{{ .Name }}" content="{{ .Value }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Title }}
|
||||
<title>{{ .Title }} | Handmade Network</title>
|
||||
{{ else }}
|
||||
<title>Handmade Network</title>
|
||||
{{ end }}
|
||||
<meta name="theme-color" content="#346ba6">
|
||||
|
||||
<script src="{{ static "js/templates.js" }}"></script>
|
||||
|
||||
<link rel="stylesheet" href="{{ static "fonts/mohave/stylesheet.css" }}">
|
||||
<link href='https://fonts.googleapis.com/css?family=Fira+Sans:300,400,500,600' rel='stylesheet' type='text/css'>
|
||||
<link href='https://fonts.googleapis.com/css?family=Fira+Mono:300,400,500,700' rel='stylesheet' type='text/css'>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static "style.css" }}">
|
||||
|
||||
<style>
|
||||
/* Copy-paste from project.css yay */
|
||||
{{ $c := hex2color "346ba6" }}
|
||||
|
||||
{{ $themeDim := eq .Theme "dark" | ternary (lightness 0.35 $c) (lightness 0.75 $c) | color2css }}
|
||||
{{ $themeDimmer := eq .Theme "dark" | ternary (lightness 0.3 $c) (lightness 0.8 $c) | color2css }}
|
||||
{{ $themeDimmest := eq .Theme "dark" | ternary (lightness 0.2 $c) (lightness 0.85 $c) | color2css }}
|
||||
|
||||
{{ $themeDark := eq .Theme "dark" | ternary (lightness 0.30 $c) (lightness 0.35 $c) | color2css }}
|
||||
|
||||
{{ $linkColor := eq .Theme "dark" | ternary (lightness 0.55 $c) (lightness 0.35 $c) | color2css }}
|
||||
{{ $linkHoverColor := eq .Theme "dark" | ternary (lightness 0.65 $c) (lightness 0.45 $c) | color2css }}
|
||||
|
||||
:root {
|
||||
--content-background: #f8f8f8;
|
||||
--card-background: rgba(255, 255, 255, 0.1);
|
||||
--card-background-hover: rgba(255, 255, 255, 0.16);
|
||||
|
||||
--theme-color: {{ $c | color2css }};
|
||||
--theme-color-dim: {{ $themeDim }};
|
||||
--theme-color-dimmer: {{ $themeDimmer }};
|
||||
--theme-color-dimmest: {{ $themeDimmest }};
|
||||
|
||||
--timeline-content-background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(#346ba6, #814cb7)
|
||||
}
|
||||
|
||||
.user-options,
|
||||
header form,
|
||||
header .menu-bar .wiki,
|
||||
header .menu-bar .library
|
||||
{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom-color: white;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.hmn-logo {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
header a, footer a {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
header .submenu {
|
||||
background-color: #346ba6;
|
||||
}
|
||||
|
||||
#top-container {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'MohaveHMN', sans-serif;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#title {
|
||||
color: white;
|
||||
font-size: 2.4rem;
|
||||
line-height: 0.8;
|
||||
margin-top: 2rem;
|
||||
letter-spacing: -0.06rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#dates {
|
||||
font-variant: small-caps;
|
||||
font-size: 1.6rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
#tagline {
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#top-container a {
|
||||
color: white !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.actions a {
|
||||
text-decoration: none !important;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
|
||||
transition: background-color 50ms ease-in-out;
|
||||
background-color:rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.actions a:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.actions a:active {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-variant: small-caps;
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-variant: small-caps;
|
||||
font-size: 2rem;
|
||||
line-height: 0.8;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.section a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emphasized {
|
||||
padding-left: 1rem;
|
||||
border-left: 0.3rem solid white;
|
||||
}
|
||||
|
||||
.flex-fair {
|
||||
flex-basis: 1px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.section li p {
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 2px solid white;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer h2 {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-item {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.carousel-thinger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 6rem;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.1); /* bg-white-10 */
|
||||
border-radius: 0.5rem; /* br3 */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.carousel-thinger.prev {
|
||||
left: -7rem;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background: linear-gradient(to left, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
.carousel-thinger.next {
|
||||
right: -7rem;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 30em) {
|
||||
/* not small styles */
|
||||
|
||||
#top-container {
|
||||
margin: 5.4rem 0;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 31rem;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 5.2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
#dates {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
|
||||
#tagline {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2.2rem;
|
||||
}
|
||||
|
||||
.actions a {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 3.4rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 2.4rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3.mt0 {
|
||||
margin-top: 0; /* ugh seriously */
|
||||
}
|
||||
|
||||
.back-to-normal * {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
}
|
||||
|
||||
.back-to-normal h1,
|
||||
.back-to-normal h2,
|
||||
.back-to-normal h3,
|
||||
.back-to-normal h4,
|
||||
.back-to-normal h5
|
||||
{
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.back-to-normal a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.snippet-project {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* More copy-paste from project.css */
|
||||
.bg-theme {
|
||||
background-color: {{ $c | color2css }};
|
||||
background-color: var(--theme-color);
|
||||
}
|
||||
|
||||
.bg-theme-dim {
|
||||
background-color: {{ $themeDim }};
|
||||
background-color: var(--theme-color-dim);
|
||||
}
|
||||
|
||||
.bg-theme-dimmer {
|
||||
background-color: {{ $themeDimmer }};
|
||||
background-color: var(--theme-color-dimmer);
|
||||
}
|
||||
|
||||
.bg-theme-dimmest {
|
||||
background-color: {{ $themeDimmest }};
|
||||
background-color: var(--theme-color-dimmest);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="{{ static "js/carousel.js" }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="left white">
|
||||
<div class="mt4-ns mw8 margin-center ph3-m ph4-l">
|
||||
{{ template "header.html" . }}
|
||||
</div>
|
||||
|
||||
{{ block "content" . }}{{ end }}
|
||||
|
||||
<div class="mw8 margin-center ph3-m ph4-l">
|
||||
{{ template "footer.html" . }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -15,7 +15,7 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
#description.drop {
|
||||
#full_description.drop {
|
||||
box-shadow: inset 0px 0px 5px yellow;
|
||||
}
|
||||
</style>
|
||||
|
@ -26,7 +26,7 @@
|
|||
{{ if .Editing }}
|
||||
<h1>Edit {{ .ProjectSettings.Name }}</h1>
|
||||
{{ else }}
|
||||
<h1>Create a new project</h1>
|
||||
<h1>Create a new {{ if .ProjectSettings.JamParticipation }}jam {{ end }}project</h1>
|
||||
{{ end }}
|
||||
<form id="project_form" class="tabbed edit-form" method="POST" enctype="multipart/form-data">
|
||||
{{ csrftoken .Session }}
|
||||
|
@ -87,6 +87,19 @@
|
|||
<div class="c--dim f7" id="tag-discord-info">If you have linked your Discord account, any #project-showcase messages with the tag "&<span id="tag-preview"></span>" will automatically be associated with this project.</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .ProjectSettings.JamParticipation }}
|
||||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Jam Participation</div>
|
||||
<div class="pt-input-ns">
|
||||
{{ range .ProjectSettings.JamParticipation }}
|
||||
<div class="pb1">
|
||||
<input id="jam_{{ .JamSlug }}" type="checkbox" name="jam_participation" value="{{ .JamSlug }}" {{ if .Participating }}checked{{ end }} />
|
||||
<label for="jam_{{ .JamSlug }}">{{ .JamName }}</label>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if and .Editing .User.IsStaff }}
|
||||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Admin settings</div>
|
||||
|
@ -137,7 +150,7 @@
|
|||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Short description:</div>
|
||||
<div>
|
||||
<textarea required maxlength="140" name="shortdesc">
|
||||
<textarea id="description" required maxlength="140" name="shortdesc">
|
||||
{{- .ProjectSettings.Blurb -}}
|
||||
</textarea>
|
||||
<div class="c--dim f7">Plaintext only. No links or markdown.</div>
|
||||
|
@ -159,7 +172,7 @@
|
|||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Full description:</div>
|
||||
<div>
|
||||
<textarea id="description" class="w-100 h5 minh-5 mono lh-copy" name="description">
|
||||
<textarea id="full_description" class="w-100 h5 minh-5 mono lh-copy" name="full_description">
|
||||
{{- .ProjectSettings.Description -}}
|
||||
</textarea>
|
||||
<div class="flex justify-end items-center mt2">
|
||||
|
@ -250,7 +263,7 @@
|
|||
{{ else }}
|
||||
const projectName = "{{ .Project.Name }}";
|
||||
{{ end }}
|
||||
const description = document.querySelector('#description');
|
||||
const description = document.querySelector('#full_description');
|
||||
const descPreview = document.querySelector('#desc-preview');
|
||||
const { clear: clearDescription } = autosaveContent({
|
||||
inputEl: description,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
{{ range .Screenshots }}
|
||||
<link rel="preload" href="{{ . }}" as="image">
|
||||
{{ end }}
|
||||
<script src="{{ static "js/templates.js" }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
|
@ -74,11 +75,17 @@
|
|||
{{ .Project.Blurb }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ with .RecentActivity }}
|
||||
{{ if or .Header.Project.CanEdit (gt (len .RecentActivity) 0) }}
|
||||
<div class="content-block timeline-container ph3 ph0-ns mv4">
|
||||
<div class="flex flex-row items-center mb2">
|
||||
<h2>Recent Activity</h2>
|
||||
<div class="flex-grow-1"></div>
|
||||
{{ if .Header.Project.CanEdit }}
|
||||
<a href="javascript:;" class="create_snippet_link button">Add Snippet</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="timeline">
|
||||
{{ range . }}
|
||||
{{ range .RecentActivity }}
|
||||
{{ template "timeline_item.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
@ -86,6 +93,35 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if .User }}
|
||||
{{ template "snippet_edit.html" . }}
|
||||
{{ if .Header.Project.CanEdit }}
|
||||
<script>
|
||||
const userName = "{{ .User.Name }}";
|
||||
const userAvatar = "{{ .User.AvatarUrl }}";
|
||||
const userUrl = "{{ .User.ProfileUrl }}";
|
||||
const currentProjectId = {{ .Project.ID }};
|
||||
|
||||
document.querySelector(".create_snippet_link")?.addEventListener("click", function() {
|
||||
let snippetEdit = makeSnippetEdit(userName, userAvatar, userUrl, new Date(), "", null, [currentProjectId], 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, currentProjectId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<script>
|
||||
const numCarouselItems = {{ len .Screenshots }};
|
||||
|
||||
|
|
|
@ -1,42 +1,27 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "extrahead" }}
|
||||
<script src="{{ static "js/templates.js" }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
<div class="flex flex-column pa3 mb2 br3">
|
||||
<div class="mb2 flex items-center">
|
||||
<img class="avatar-icon lite mr2" src="{{ .Snippet.OwnerAvatarUrl }}"/>
|
||||
<a class="user" href="{{ .Snippet.OwnerUrl }}">{{ .Snippet.OwnerName }}</a>
|
||||
<a class="tr" style="flex: 1 1 auto;" href="{{ .Snippet.Url }}">{{ timehtml (relativedate .Snippet.Date) .Snippet.Date }}</a>
|
||||
</div>
|
||||
<div class="pre overflow-auto mb2">{{ .Snippet.Description }}</div>
|
||||
<div>
|
||||
{{ range .Snippet.EmbedMedia }}
|
||||
{{ if eq .Type mediaimage }}
|
||||
<img src="{{ .AssetUrl }}">
|
||||
{{ else if eq .Type mediavideo }}
|
||||
<video src="{{ .AssetUrl }}" preload="metadata" controls>
|
||||
{{ else if eq .Type mediaaudio }}
|
||||
<audio src="{{ .AssetUrl }}" controls>
|
||||
{{ else if eq .Type mediaembed }}
|
||||
<div class="mb3 aspect-ratio aspect-ratio--16x9">
|
||||
{{ .EmbedHTML }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="project-card br2 pv1 ph2">
|
||||
<a href="{{ .AssetUrl }}" target="_blank">{{ .Filename }} ({{ filesize .FileSize }})</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ with .Snippet.Tags }}
|
||||
<div class="mt3 flex">
|
||||
{{ range $i, $tag := . }}
|
||||
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
||||
{{ $tag.Text }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "timeline_item.html" .Snippet }}
|
||||
</div>
|
||||
{{ if .CanEditSnippet }}
|
||||
{{ template "snippet_edit.html" . }}
|
||||
<script>
|
||||
document.querySelector(".timeline-item .edit").addEventListener("click", function(ev) {
|
||||
if (ev.target.classList.contains("edit")) {
|
||||
let parent = ev.target.parentElement;
|
||||
while (parent && !parent.classList.contains("timeline-item")) {
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
if (parent && parent.classList.contains("timeline-item")) {
|
||||
editTimelineSnippet(parent);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
}
|
||||
|
||||
</style>
|
||||
<script src="{{ static "js/templates.js" }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
|
@ -143,19 +144,15 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if eq 1 0 }}
|
||||
<div class="content-block ph3 ph0-ns">
|
||||
<h2>Add Snippets</h2>
|
||||
<div class="note">
|
||||
Show us what you're working on.<br />
|
||||
You can upload videos, images, and audio clips.<br />
|
||||
Your snippets may appear on the <a href="{{ .ShowcaseUrl }}">showcase page</a>.
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if gt (len .TimelineItems) 0 }}
|
||||
{{ if or .OwnProfile (gt (len .TimelineItems) 0) }}
|
||||
<div class="mv3 content-block timeline-container ph3 ph0-ns">
|
||||
<div class="flex flex-row items-center">
|
||||
<h2>Recent Activity</h2>
|
||||
<div class="flex-grow-1"></div>
|
||||
{{ if .OwnProfile }}
|
||||
<a href="javascript:;" class="create_snippet_link button">Add Snippet</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="timeline-filters mb2">
|
||||
</div>
|
||||
<div class="timeline">
|
||||
|
@ -167,6 +164,36 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .User }}
|
||||
{{ template "snippet_edit.html" . }}
|
||||
<script>
|
||||
const userName = "{{ .User.Name }}";
|
||||
const userAvatar = "{{ .User.AvatarUrl }}";
|
||||
const userUrl = "{{ .User.ProfileUrl }}";
|
||||
|
||||
|
||||
{{ if .OwnProfile }}
|
||||
document.querySelector(".create_snippet_link")?.addEventListener("click", function() {
|
||||
let snippetEdit = makeSnippetEdit(userName, userAvatar, userUrl, new Date(), "", null, [], null, null, 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, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
<script>
|
||||
const filterTitles = [];
|
||||
for (const item of document.querySelectorAll('.timeline-item')) {
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
{{ template "wheeljam_2022_base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<style>
|
||||
:root {
|
||||
--theme-color: white;
|
||||
--theme-color-dimmer: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#title {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="top-container" class="flex flex-column items-center ph3">
|
||||
<h1 id="title">Wheel Reinvention Jam</h1>
|
||||
<h2 id="dates">August 15 - 21, 2O22</h2>
|
||||
<div id="tagline" class="center">
|
||||
A one-week jam to change the status quo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section bg-black-20 pt4 pb3 pb4-ns">
|
||||
<div class="mw8 margin-center ph3 ph4-l flex flex-column flex-row-ns g3">
|
||||
<div class="flex-grow-1">
|
||||
{{ if eq .DaysUntilEnd 0 }}
|
||||
<h3 class="mt0 mb3">Project updates</h3>
|
||||
{{ else }}
|
||||
<h3 class="mt0 mb3">Recent updates</h3>
|
||||
{{ end }}
|
||||
<div class="timeline">
|
||||
{{ range .TimelineItems }}
|
||||
{{ template "timeline_item.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-40-ns flex-shrink-0">
|
||||
<h3 class="mt0 mb3">Projects</h3>
|
||||
<div class="projects flex flex-column g3 back-to-normal">
|
||||
{{ range .JamProjects }}
|
||||
{{ template "project_card.html" projectcarddata . "" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -1,279 +1,52 @@
|
|||
{{/*
|
||||
This is a copy-paste from base.html because we want to preserve the unique
|
||||
style of this page no matter what future changes we make to the base.
|
||||
*/}}
|
||||
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{ template "wheeljam_2022_base.html" . }}
|
||||
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ static "wheeljam2022/favicon-16x16.png" }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ static "wheeljam2022/favicon-32x32.png" }}">
|
||||
|
||||
{{ if .CanonicalLink }}<link rel="canonical" href="{{ .CanonicalLink }}">{{ end }}
|
||||
{{ range .OpenGraphItems }}
|
||||
{{ if .Property }}
|
||||
<meta property="{{ .Property }}" content="{{ .Value }}" />
|
||||
{{ else }}
|
||||
<meta name="{{ .Name }}" content="{{ .Value }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Title }}
|
||||
<title>{{ .Title }} | Handmade Network</title>
|
||||
{{ else }}
|
||||
<title>Handmade Network</title>
|
||||
{{ end }}
|
||||
<meta name="theme-color" content="#346ba6">
|
||||
|
||||
<link rel="stylesheet" href="{{ static "fonts/mohave/stylesheet.css" }}">
|
||||
<link href='https://fonts.googleapis.com/css?family=Fira+Sans:300,400,500,600' rel='stylesheet' type='text/css'>
|
||||
<link href='https://fonts.googleapis.com/css?family=Fira+Mono:300,400,500,700' rel='stylesheet' type='text/css'>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static "style.css" }}">
|
||||
{{ define "content" }}
|
||||
{{ $discordInviteURL := "https://discord.gg/zFt8Rf59?event=1004511448107602031" }}
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--content-background: #f8f8f8;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(#346ba6, #814cb7)
|
||||
}
|
||||
|
||||
.user-options,
|
||||
header form,
|
||||
header .menu-bar .wiki,
|
||||
header .menu-bar .library
|
||||
{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom-color: white;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.hmn-logo {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
header a, footer a {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
header .submenu {
|
||||
background-color: #346ba6;
|
||||
}
|
||||
|
||||
#top-container {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'MohaveHMN', sans-serif;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#title {
|
||||
color: white;
|
||||
font-size: 2.4rem;
|
||||
line-height: 0.8;
|
||||
margin-top: 2rem;
|
||||
letter-spacing: -0.06rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#dates {
|
||||
font-variant: small-caps;
|
||||
font-size: 1.6rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
#tagline {
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#top-container a {
|
||||
color: white !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#actions {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
#actions a {
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
|
||||
transition: background-color 50ms ease-in-out;
|
||||
background-color:rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#actions a:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
#actions a:active {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-variant: small-caps;
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-variant: small-caps;
|
||||
font-size: 2rem;
|
||||
line-height: 0.8;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.section a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flex-fair {
|
||||
flex-basis: 1px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.section li p {
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 2px solid white;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer h2 {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.carousel-thinger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 6rem;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.1); /* bg-white-10 */
|
||||
border-radius: 0.5rem; /* br3 */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.carousel-thinger.prev {
|
||||
left: -7rem;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background: linear-gradient(to left, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
.carousel-thinger.next {
|
||||
right: -7rem;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0.1), transparent);
|
||||
.projects {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 30em) {
|
||||
/* not small styles */
|
||||
|
||||
#top-container {
|
||||
margin: 5.4rem 0;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 31rem;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 5.2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
#dates {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
|
||||
#tagline {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
#actions {
|
||||
margin-top: 2.2rem;
|
||||
}
|
||||
|
||||
#actions a {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 3.4rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 2.4rem;
|
||||
margin-top: 1.6rem;
|
||||
.projects {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="{{ static "js/carousel.js" }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="left white">
|
||||
<div class="mt4-ns mw8 margin-center ph3-m ph4-l">
|
||||
{{ template "header.html" . }}
|
||||
</div>
|
||||
|
||||
<div id="top-container" class="flex flex-column items-center ph3">
|
||||
<img id="logo" src="{{ static "wheeljam2022/logo.svg" }}">
|
||||
<h1 id="title">Wheel Reinvention Jam</h1>
|
||||
<h2 id="dates">August 15 - 21, 2O22</h2>
|
||||
<div id="tagline" class="center">
|
||||
A one-week jam to change the status quo.
|
||||
</div>
|
||||
<div id="actions" class="flex justify-center">
|
||||
{{ if gt .DaysUntilStart 0 }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns" target="_blank" href="https://github.com/HandmadeNetwork/wishlist/discussions">Choose a project</a>
|
||||
{{ if gt .DaysUntilEnd 0 }}
|
||||
{{ if eq .DaysUntilStart 0 }}
|
||||
<b>Happening now.</b>
|
||||
{{ else if eq .DaysUntilStart 1 }}
|
||||
<b>Starting tomorrow.</b>
|
||||
{{ else }}
|
||||
<!-- TODO: A reasonable call to action! -->
|
||||
<b>In {{ .DaysUntilStart }} days.</b>
|
||||
{{ end }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="https://discord.gg/hmn">Join the Discord</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="actions flex justify-center">
|
||||
{{ if gt .DaysUntilStart 0 }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns" target="_blank" href="https://github.com/HandmadeNetwork/wishlist/discussions">Find a project</a>
|
||||
{{ else if gt .DaysUntilEnd 0 }}
|
||||
{{ if .SubmittedProjectUrl }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns" target="_blank" href="{{ .SubmittedProjectUrl }}">Share your progress</a>
|
||||
{{ else }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ .ProjectSubmissionUrl }}">Create your project</a>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" href="{{ .ShowcaseFeedUrl }}">See the results</a>
|
||||
{{ end }}
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ $discordInviteURL }}">Join the Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -292,6 +65,145 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{{ if eq .DaysUntilEnd 0 }}
|
||||
<div class="section bg-black-20 pv4 overflow-hidden">
|
||||
<div class="mw8 margin-center ph3 ph4-l">
|
||||
<h2>Submitted projects</h2>
|
||||
<div class="mt3 projects g3 back-to-normal">
|
||||
{{ range .JamProjects }}
|
||||
{{ template "project_card.html" projectcarddata . "" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="actions flex justify-center">
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" href="{{ .ShowcaseFeedUrl }}">See all updates</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else if and (eq .DaysUntilStart 0) (not (eq .ShowcaseJson "[]")) }}
|
||||
<div id="showcase-outer-container" class="bg-black-20 pt4 pb3 pb4-ns">
|
||||
<div class="section mw8 margin-center ph3 ph4-l">
|
||||
{{ if gt .DaysUntilEnd 0 }}
|
||||
<h2>Recent updates</h2>
|
||||
<p>
|
||||
These screenshots and videos were shared by jam participants in <b>#project-showcase</b> on our <a href="{{ $discordInviteURL }}" target="_blank">Discord</a>. Join us and share what you're working on! <a class="b" href="{{ .ShowcaseFeedUrl }}">See all ➜</a>
|
||||
</p>
|
||||
{{ else }}
|
||||
<h2>Community showcase</h2>
|
||||
<p>
|
||||
These screenshots and videos were shared by jam participants in <b>#project-showcase</b> on our <a href="https://discord.gg/hmn" target="_blank">Discord</a> during the jam. Join us and chat about your favorites!
|
||||
</p>
|
||||
{{ end }}
|
||||
<div id="showcase-container" class="mw8 center-layout mh2 mh0-ns"></div>
|
||||
<div class="actions flex justify-center">
|
||||
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ .ShowcaseFeedUrl }}">See all</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "showcase_templates.html" }}
|
||||
<!-- Copy-pasted and mangled from showcase.html -->
|
||||
<script>
|
||||
const ROW_HEIGHT = 300;
|
||||
const ITEM_SPACING = 4;
|
||||
|
||||
const showcaseItems = JSON.parse("{{ .ShowcaseJson }}");
|
||||
const addThumbnailFuncs = new Array(showcaseItems.length);
|
||||
|
||||
const showcaseOuterContainer = document.querySelector('#showcase-outer-container');
|
||||
let showcaseContainer = document.querySelector('#showcase-container');
|
||||
|
||||
// showcaseOuterContainer.classList.toggle('dn', showcaseItems.length === 0);
|
||||
|
||||
const itemElements = []; // array of arrays
|
||||
for (let i = 0; i < showcaseItems.length; i++) {
|
||||
const item = showcaseItems[i];
|
||||
|
||||
const [itemEl, addThumbnail] = makeShowcaseItem(item);
|
||||
itemEl.container.setAttribute('data-index', i);
|
||||
itemEl.container.setAttribute('data-date', item.date);
|
||||
|
||||
addThumbnailFuncs[i] = addThumbnail;
|
||||
|
||||
itemElements.push(itemEl.container);
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const width = showcaseContainer.getBoundingClientRect().width;
|
||||
showcaseContainer = emptyElement(showcaseContainer);
|
||||
|
||||
function addRow(itemEls, rowWidth, container) {
|
||||
const totalSpacing = ITEM_SPACING * (itemEls.length - 1);
|
||||
const scaleFactor = (width / Math.max(rowWidth, width));
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.classList.add('flex');
|
||||
row.classList.toggle('justify-between', rowWidth >= width);
|
||||
row.style.marginBottom = `${ITEM_SPACING}px`;
|
||||
|
||||
for (const itemEl of itemEls) {
|
||||
const index = parseInt(itemEl.getAttribute('data-index'), 10);
|
||||
const item = showcaseItems[index];
|
||||
|
||||
const aspect = item.width / item.height;
|
||||
const baseWidth = (aspect * ROW_HEIGHT) * scaleFactor;
|
||||
const actualWidth = baseWidth - (totalSpacing / itemEls.length);
|
||||
|
||||
itemEl.style.width = `${actualWidth}px`;
|
||||
itemEl.style.height = `${scaleFactor * ROW_HEIGHT}px`;
|
||||
itemEl.style.marginRight = `${ITEM_SPACING}px`;
|
||||
|
||||
row.appendChild(itemEl);
|
||||
}
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
let rowItemEls = [];
|
||||
let rowWidth = 0;
|
||||
let numRows = 0;
|
||||
|
||||
for (const itemEl of itemElements) {
|
||||
const index = parseInt(itemEl.getAttribute('data-index'), 10);
|
||||
const item = showcaseItems[index];
|
||||
|
||||
const aspect = item.width / item.height;
|
||||
rowWidth += aspect * ROW_HEIGHT;
|
||||
|
||||
rowItemEls.push(itemEl);
|
||||
|
||||
if (rowWidth > width) {
|
||||
addRow(rowItemEls, rowWidth, showcaseContainer);
|
||||
numRows += 1;
|
||||
if (numRows == 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
rowItemEls = [];
|
||||
rowWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
addRow(rowItemEls, rowWidth, showcaseContainer);
|
||||
}
|
||||
|
||||
function loadImages() {
|
||||
const items = showcaseContainer.querySelectorAll('.showcase-item');
|
||||
for (const item of items) {
|
||||
const i = parseInt(item.getAttribute('data-index'), 10);
|
||||
addThumbnailFuncs[i]();
|
||||
}
|
||||
}
|
||||
|
||||
layout();
|
||||
layout(); // scrollbars are fun!!
|
||||
|
||||
loadImages();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
layout();
|
||||
});
|
||||
</script>
|
||||
{{ else }}
|
||||
<div class="section bg-black-20 pv4 overflow-hidden">
|
||||
<div class="mw8 margin-center ph3 ph4-l">
|
||||
<h2>Last year's entries</h2>
|
||||
|
@ -348,55 +260,84 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="pt4 pb3 pb4-ns">
|
||||
<div class="section mw8 margin-center ph3 ph4-l">
|
||||
<h2>Details / Rules</h2>
|
||||
<h2>How to participate</h2>
|
||||
<p>
|
||||
The jam takes place from Monday, August 15 through Sunday, August 21. Here's how you can participate:
|
||||
</p>
|
||||
|
||||
<div class="{{ if gt .DaysUntilStart 0 }}emphasized{{ end }}">
|
||||
<h3>Pick a project and form a team.</h3>
|
||||
<p>
|
||||
Find a project idea that excites you! Whether there's a project you've always wanted to start, or you're looking for inspiration, join the conversation over on our <a href="https://github.com/HandmadeNetwork/wishlist/discussions" target="_blank">Wishlist</a>, brainstorm project ideas, and find a team.
|
||||
Find a project idea that excites you! Join the conversation over on our <a href="https://github.com/HandmadeNetwork/wishlist/discussions" target="_blank">Wishlist</a>, brainstorm ideas in <b>#jam</b> on <a href="{{ $discordInviteURL }}" target="_blank">Discord</a>, or just invite some friends to jam with you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="{{ if and (eq .DaysUntilStart 0) (gt .DaysUntilEnd 1) }}emphasized{{ end }}">
|
||||
<h3>Jam.</h3>
|
||||
<p>
|
||||
Create a personal Handmade Network project to track your work. Then, build your program over the course of the week. Sharing your work in progress is highly recommended - you can share WIP content in #project-showcase on Discord, or directly from your project page.
|
||||
{{ if and (eq .DaysUntilStart 0) (not .SubmittedProjectUrl) }}
|
||||
<a href="{{ .ProjectSubmissionUrl }}" target="_blank"><b>Create a Handmade Network project</b></a>
|
||||
{{ else }}
|
||||
After the jam starts, create a Handmade Network project
|
||||
{{ end }}
|
||||
to track your work. Then, build your program! Share your work in progress in #project-showcase on Discord, or directly from your project page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="{{ if eq .DaysUntilEnd 1 }}emphasized{{ end }}">
|
||||
<h3>Submit your work!</h3>
|
||||
<p>
|
||||
Your Handmade Network project acts as your submission. Fill out the project description, making sure to explain the goals of the project and how it improves on what came before. Also consider posting an update on the project with video of your program in action!
|
||||
<b>Your Handmade Network project is your submission.</b> Fill out the project description, making sure to explain the goals of the project and how it improves on what came before. Also consider posting an update with video of your program in action!
|
||||
</p>
|
||||
|
||||
<h3>Rules</h3>
|
||||
<ul>
|
||||
<li>Any tech is allowed, but in the spirit of the <a href="https://handmade.network/manifesto">Handmade ethos</a>, we encourage you to use only use what you really need. If you want some lightweight templates to get you started, check out our <a href="https://github.com/HandmadeNetwork/jam_templates" target="_blank">app templates</a>.</li>
|
||||
<li>Working with a team is strongly encouraged, but working solo is allowed.</li>
|
||||
<li>
|
||||
<p>Submit your work by the end of August 21 in your local timezone.</p>
|
||||
<ul>
|
||||
<li>Update the description of your Handmade Network project. The project is your submission.</li> {{/* TODO: dynamically link to their project? */}}
|
||||
<li>Explain the project's goals and how it improves on what came before. Also share some closing thoughts - did it turn out how you hoped? What did you learn? If you continue the project, what will you do differently?</li>
|
||||
<li>
|
||||
{{ if and (eq .DaysUntilStart 0) (gt .DaysUntilEnd 0) }}
|
||||
<p>
|
||||
Your description must contain multiple screenshots of your software in action. You should ideally also share a project update with a demo video.
|
||||
Submissions close <b><span class="countdown" data-deadline="{{ .EndTimeUnix }}"></span></b>.
|
||||
</p>
|
||||
{{ else if eq .DaysUntilEnd 0 }}
|
||||
<p>
|
||||
We recommend Mārtiņš Možeiko's <a href="https://wcap.handmade.network/" target="_blank">wcap</a> for recording desktop video on Windows. On Mac, just press ⌘-Option-5 and record a video, or use QuickTime.
|
||||
<b>Submissions are now closed.</b>
|
||||
</p>
|
||||
</li>
|
||||
<li>If at all possible, please provide a way for people to either build or download your program.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>There are no explicit winners, but we will be selecting a few of our favorite projects to highlight in a recap stream following the jam.</li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-black-20 pt4 pb3 pb4-ns">
|
||||
<div class="section mw8 margin-center ph3 ph4-l">
|
||||
<h2>Rules</h2>
|
||||
<ul>
|
||||
<li>Any tech is allowed, but we encourage you to use only use what you really need. If you want some lightweight templates to get you started, check out our <a href="https://github.com/HandmadeNetwork/jam_templates" target="_blank">app templates</a>.</li>
|
||||
<li>You may work solo or in a team. (But we encourage you to work with a team!)</li>
|
||||
<li>Submit your work by the end of the day on August 21.</li>
|
||||
</ul>
|
||||
<p>There are no explicit winners, but we will be selecting a few of our favorite projects to highlight in a recap stream following the jam.</p>
|
||||
|
||||
<h3>Submission rules</h3>
|
||||
<p>
|
||||
<b>{{ with .SubmittedProjectUrl }}
|
||||
<a href="{{ . }}" target="_blank">Your Handmade Network project</a>
|
||||
{{ else }}
|
||||
Your Handmade Network project
|
||||
{{ end }}
|
||||
is your submission.</b> We will be looking at the project's description and any extra updates you share toward the end of the jam.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Explain the project's goals and how it improves on what came before. Also share some closing thoughts - did it turn out how you hoped? What did you learn? If you continue the project, what will you do differently?
|
||||
</li>
|
||||
<li>
|
||||
<b>Your description must contain multiple screenshots of your software in action.</b> You should ideally also share a project update with a demo video. We recommend Mārtiņš Možeiko's <a href="https://wcap.handmade.network/" target="_blank">wcap</a> for recording desktop video on Windows. On Mac, just press ⌘-Option-5 and record a video, or use QuickTime.
|
||||
</li>
|
||||
<li>If at all possible, please provide a way for people to either build or download your program.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt4">
|
||||
<div class="flex-ns flex-row-ns mw8 margin-center ph3 ph4-l">
|
||||
<div class="section flex-fair mb4 mb0-ns">
|
||||
<h2>Make it by hand.</h2>
|
||||
|
@ -422,14 +363,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mw8 margin-center ph3-m ph4-l">
|
||||
{{ template "footer.html" . }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const carouselContainer = document.querySelector('.carousel-container');
|
||||
|
||||
if (carouselContainer) {
|
||||
const { next, prev } = initCarousel(carouselContainer, {
|
||||
onChange() {
|
||||
if (carouselContainer.getBoundingClientRect().top < 0) {
|
||||
|
@ -446,7 +382,34 @@
|
|||
.addEventListener('click', () => {
|
||||
prev();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<script>
|
||||
for (const countdown of document.querySelectorAll('.countdown')) {
|
||||
const deadline = countdown.getAttribute('data-deadline');
|
||||
const deadlineDate = new Date(parseInt(deadline, 10) * 1000);
|
||||
|
||||
function updateCountdown() {
|
||||
const remainingMs = deadlineDate.getTime() - new Date().getTime();
|
||||
const remainingMinutes = remainingMs / 1000 / 60;
|
||||
const remainingHours = remainingMinutes / 60;
|
||||
const remainingDays = remainingHours / 24; // no daylight savings transitions during the jam mmkay
|
||||
|
||||
let str = 'imminently';
|
||||
if (remainingMinutes < 60) {
|
||||
str = `in ${Math.ceil(remainingMinutes)} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`;
|
||||
} else if (remainingHours < 24) {
|
||||
str = `in ${Math.ceil(remainingHours)} ${remainingHours === 1 ? 'hour' : 'hours'}`;
|
||||
} else {
|
||||
str = `in ${Math.ceil(remainingDays)} ${remainingDays === 1 ? 'day' : 'days'}`;
|
||||
}
|
||||
|
||||
countdown.innerText = str;
|
||||
}
|
||||
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000 * 60);
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -39,7 +40,11 @@ func Init() {
|
|||
t := template.New(f.Name())
|
||||
t = t.Funcs(sprig.FuncMap())
|
||||
t = t.Funcs(HMNTemplateFuncs)
|
||||
t, err := t.ParseFS(templateFs, "src/layouts/*.html", "src/include/*.html", "src/"+f.Name())
|
||||
t, err := t.ParseFS(templateFs,
|
||||
"src/layouts/*",
|
||||
"src/include/*",
|
||||
"src/"+f.Name(),
|
||||
)
|
||||
if err != nil {
|
||||
logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template")
|
||||
}
|
||||
|
@ -79,6 +84,8 @@ func names(ts []*template.Template) []string {
|
|||
//go:embed svg/*
|
||||
var SVGs embed.FS
|
||||
|
||||
var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
|
||||
|
||||
var HMNTemplateFuncs = template.FuncMap{
|
||||
"add": func(a int, b ...int) int {
|
||||
for _, num := range b {
|
||||
|
@ -216,6 +223,12 @@ var HMNTemplateFuncs = template.FuncMap{
|
|||
}
|
||||
return fmt.Sprintf("%.*f%s", precision, num, scales[scale])
|
||||
},
|
||||
"cleancontrolchars": func(str template.HTML) template.HTML {
|
||||
return template.HTML(controlCharRegex.ReplaceAllString(string(str), ""))
|
||||
},
|
||||
"trim": func(str template.HTML) template.HTML {
|
||||
return template.HTML(strings.TrimSpace(string(str)))
|
||||
},
|
||||
|
||||
// NOTE(asaf): Template specific functions:
|
||||
"projectcarddata": func(project Project, classes string) ProjectCardData {
|
||||
|
|
|
@ -115,6 +115,7 @@ type Post struct {
|
|||
}
|
||||
|
||||
type Project struct {
|
||||
ID int
|
||||
Name string
|
||||
Subdomain string
|
||||
Color1 string
|
||||
|
@ -146,6 +147,7 @@ type ProjectSettings struct {
|
|||
Personal bool
|
||||
Lifecycle string
|
||||
Tag string
|
||||
JamParticipation []ProjectJamParticipation
|
||||
|
||||
Blurb string
|
||||
Description string
|
||||
|
@ -156,6 +158,18 @@ type ProjectSettings struct {
|
|||
DarkLogo string
|
||||
}
|
||||
|
||||
type ProjectJamParticipation struct {
|
||||
JamName string
|
||||
JamSlug string
|
||||
Participating bool
|
||||
}
|
||||
|
||||
type SnippetEdit struct {
|
||||
AvailableProjectsJSON string
|
||||
SubmitUrl string
|
||||
AssetMaxSize int
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Username string
|
||||
|
@ -280,6 +294,7 @@ type ThreadListItem struct {
|
|||
}
|
||||
|
||||
type TimelineItem struct {
|
||||
ID string
|
||||
Date time.Time
|
||||
Title string
|
||||
TypeTitle string
|
||||
|
@ -292,8 +307,9 @@ type TimelineItem struct {
|
|||
OwnerName string
|
||||
OwnerUrl string
|
||||
|
||||
Tags []Tag
|
||||
Projects []Project
|
||||
Description template.HTML
|
||||
RawDescription string
|
||||
|
||||
PreviewMedia TimelineItemMedia
|
||||
EmbedMedia []TimelineItemMedia
|
||||
|
@ -302,6 +318,7 @@ type TimelineItem struct {
|
|||
AllowTitleWrap bool
|
||||
TruncateDescription bool
|
||||
CanShowcase bool // whether this snippet can be shown in a showcase gallery
|
||||
Editable bool
|
||||
}
|
||||
|
||||
type TimelineItemMediaType int
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
@ -128,8 +129,8 @@ type unapprovedUserData struct {
|
|||
User templates.User
|
||||
Date time.Time
|
||||
UserLinks []templates.Link
|
||||
Posts []postWithTitle
|
||||
ProjectsWithLinks []projectWithLinks
|
||||
Timeline []templates.TimelineItem
|
||||
}
|
||||
|
||||
func AdminApprovalQueue(c *RequestContext) ResponseData {
|
||||
|
@ -138,6 +139,25 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
potentialUsers, err := db.QueryScalar[int](c, c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM hmn_user
|
||||
WHERE hmn_user.status = $1
|
||||
`,
|
||||
models.UserStatusConfirmed,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved users"))
|
||||
}
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
OwnerIDs: potentialUsers,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved snippets"))
|
||||
}
|
||||
|
||||
posts, err := fetchUnapprovedPosts(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved posts"))
|
||||
|
@ -151,6 +171,28 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
unapprovedUsers := make([]*unapprovedUserData, 0)
|
||||
userIDToDataIdx := make(map[int]int)
|
||||
|
||||
for _, s := range snippets {
|
||||
var userData *unapprovedUserData
|
||||
if idx, ok := userIDToDataIdx[s.Owner.ID]; ok {
|
||||
userData = unapprovedUsers[idx]
|
||||
} else {
|
||||
userData = &unapprovedUserData{
|
||||
User: templates.UserToTemplate(s.Owner, c.Theme),
|
||||
UserLinks: make([]templates.Link, 0, 10),
|
||||
}
|
||||
unapprovedUsers = append(unapprovedUsers, userData)
|
||||
userIDToDataIdx[s.Owner.ID] = len(unapprovedUsers) - 1
|
||||
}
|
||||
|
||||
if s.Snippet.When.After(userData.Date) {
|
||||
userData.Date = s.Snippet.When
|
||||
}
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
timelineItem.OwnerAvatarUrl = ""
|
||||
timelineItem.SmallInfo = true
|
||||
userData.Timeline = append(userData.Timeline, timelineItem)
|
||||
}
|
||||
|
||||
for _, p := range posts {
|
||||
var userData *unapprovedUserData
|
||||
if idx, ok := userIDToDataIdx[p.Author.ID]; ok {
|
||||
|
@ -167,13 +209,11 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
if p.Post.PostDate.After(userData.Date) {
|
||||
userData.Date = p.Post.PostDate
|
||||
}
|
||||
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
|
||||
post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
|
||||
post.Url = UrlForGenericPost(hmndata.UrlContextForProject(&p.Project), &p.Thread, &p.Post, lineageBuilder)
|
||||
userData.Posts = append(userData.Posts, postWithTitle{
|
||||
Post: post,
|
||||
Title: p.Thread.Title,
|
||||
})
|
||||
timelineItem := PostToTimelineItem(hmndata.UrlContextForProject(&p.Project), lineageBuilder, &p.Post, &p.Thread, &p.Author, c.Theme)
|
||||
timelineItem.OwnerAvatarUrl = ""
|
||||
timelineItem.SmallInfo = true
|
||||
timelineItem.Description = template.HTML(p.CurrentVersion.TextParsed)
|
||||
userData.Timeline = append(userData.Timeline, timelineItem)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
|
|
|
@ -26,7 +26,7 @@ const assetMaxSize = 10 * 1024 * 1024
|
|||
const assetMaxSizeAdmin = 10 * 1024 * 1024 * 1024
|
||||
|
||||
func AssetMaxSize(user *models.User) int {
|
||||
if user.IsStaff {
|
||||
if user != nil && user.IsStaff {
|
||||
return assetMaxSizeAdmin
|
||||
} else {
|
||||
return assetMaxSize
|
||||
|
|
|
@ -195,7 +195,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||
}
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
|
|
@ -14,14 +14,12 @@ import (
|
|||
func JamIndex2022(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
|
||||
jamStartTime := time.Date(2022, 8, 15, 0, 0, 0, 0, time.UTC)
|
||||
jamEndTime := time.Date(2022, 8, 22, 0, 0, 0, 0, time.UTC)
|
||||
daysUntilStart := daysUntil(jamStartTime)
|
||||
daysUntilEnd := daysUntil(jamEndTime)
|
||||
daysUntilStart := daysUntil(hmndata.WRJ2022.StartTime)
|
||||
daysUntilEnd := daysUntil(hmndata.WRJ2022.EndTime)
|
||||
|
||||
baseData := getBaseDataAutocrumb(c, "Wheel Reinvention Jam 2022")
|
||||
baseData := getBaseDataAutocrumb(c, hmndata.WRJ2022.Name)
|
||||
baseData.OpenGraphItems = []templates.OpenGraphItem{
|
||||
{Property: "og:site_name", Value: "Handmade Network"},
|
||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||
{Property: "og:type", Value: "website"},
|
||||
{Property: "og:image", Value: hmnurl.BuildPublic("wheeljam2022/opengraph.png", true)},
|
||||
{Property: "og:description", Value: "A one-week jam to change the status quo. August 15 - 21 on Handmade Network."},
|
||||
|
@ -31,12 +29,147 @@ func JamIndex2022(c *RequestContext) ResponseData {
|
|||
type JamPageData struct {
|
||||
templates.BaseData
|
||||
DaysUntilStart, DaysUntilEnd int
|
||||
StartTimeUnix, EndTimeUnix int64
|
||||
|
||||
SubmittedProjectUrl string
|
||||
ProjectSubmissionUrl string
|
||||
ShowcaseFeedUrl string
|
||||
ShowcaseJson string
|
||||
|
||||
JamProjects []templates.Project
|
||||
}
|
||||
|
||||
var showcaseItems []templates.TimelineItem
|
||||
submittedProjectUrl := ""
|
||||
|
||||
if c.CurrentUser != nil {
|
||||
projects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{c.CurrentUser.ID},
|
||||
JamSlugs: []string{hmndata.WRJ2022.Slug},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
|
||||
}
|
||||
if len(projects) > 0 {
|
||||
urlContext := hmndata.UrlContextForProject(&projects[0].Project)
|
||||
submittedProjectUrl = urlContext.BuildHomepage()
|
||||
}
|
||||
}
|
||||
|
||||
jamProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
JamSlugs: []string{hmndata.WRJ2022.Slug},
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
|
||||
}
|
||||
|
||||
pageProjects := make([]templates.Project, 0, len(jamProjects))
|
||||
for _, p := range jamProjects {
|
||||
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme))
|
||||
}
|
||||
|
||||
projectIds := make([]int, 0, len(jamProjects))
|
||||
for _, jp := range jamProjects {
|
||||
projectIds = append(projectIds, jp.Project.ID)
|
||||
}
|
||||
|
||||
if len(projectIds) > 0 {
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
ProjectIDs: projectIds,
|
||||
Limit: 12,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for jam showcase"))
|
||||
}
|
||||
showcaseItems = make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showcaseJson := templates.TimelineItemsToJSON(showcaseItems)
|
||||
|
||||
res.MustWriteTemplate("wheeljam_2022_index.html", JamPageData{
|
||||
BaseData: baseData,
|
||||
DaysUntilStart: daysUntilStart,
|
||||
DaysUntilEnd: daysUntilEnd,
|
||||
StartTimeUnix: hmndata.WRJ2022.StartTime.Unix(),
|
||||
EndTimeUnix: hmndata.WRJ2022.EndTime.Unix(),
|
||||
ProjectSubmissionUrl: hmnurl.BuildProjectNewJam(),
|
||||
SubmittedProjectUrl: submittedProjectUrl,
|
||||
ShowcaseFeedUrl: hmnurl.BuildJamFeed2022(),
|
||||
ShowcaseJson: showcaseJson,
|
||||
JamProjects: pageProjects,
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
func JamFeed2022(c *RequestContext) ResponseData {
|
||||
jamProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
JamSlugs: []string{hmndata.WRJ2022.Slug},
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
|
||||
}
|
||||
|
||||
projectIds := make([]int, 0, len(jamProjects))
|
||||
for _, jp := range jamProjects {
|
||||
projectIds = append(projectIds, jp.Project.ID)
|
||||
}
|
||||
|
||||
var timelineItems []templates.TimelineItem
|
||||
if len(projectIds) > 0 {
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
ProjectIDs: projectIds,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for jam showcase"))
|
||||
}
|
||||
|
||||
timelineItems = make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
timelineItem.SmallInfo = true
|
||||
timelineItems = append(timelineItems, timelineItem)
|
||||
}
|
||||
}
|
||||
|
||||
pageProjects := make([]templates.Project, 0, len(jamProjects))
|
||||
for _, p := range jamProjects {
|
||||
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme))
|
||||
}
|
||||
|
||||
type JamFeedData struct {
|
||||
templates.BaseData
|
||||
DaysUntilStart, DaysUntilEnd int
|
||||
|
||||
JamProjects []templates.Project
|
||||
TimelineItems []templates.TimelineItem
|
||||
}
|
||||
|
||||
daysUntilStart := daysUntil(hmndata.WRJ2022.StartTime)
|
||||
daysUntilEnd := daysUntil(hmndata.WRJ2022.EndTime)
|
||||
|
||||
baseData := getBaseDataAutocrumb(c, hmndata.WRJ2022.Name)
|
||||
baseData.OpenGraphItems = []templates.OpenGraphItem{
|
||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||
{Property: "og:type", Value: "website"},
|
||||
{Property: "og:image", Value: hmnurl.BuildPublic("wheeljam2022/opengraph.png", true)},
|
||||
{Property: "og:description", Value: "A one-week jam to change the status quo. August 15 - 21 on Handmade Network."},
|
||||
{Property: "og:url", Value: hmnurl.BuildJamIndex()},
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("wheeljam_2022_feed.html", JamFeedData{
|
||||
BaseData: baseData,
|
||||
DaysUntilStart: daysUntilStart,
|
||||
DaysUntilEnd: daysUntilEnd,
|
||||
JamProjects: pageProjects,
|
||||
TimelineItems: timelineItems,
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
@ -44,8 +177,7 @@ func JamIndex2022(c *RequestContext) ResponseData {
|
|||
func JamIndex2021(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
|
||||
jamStartTime := time.Date(2021, 9, 27, 0, 0, 0, 0, time.UTC)
|
||||
daysUntilJam := daysUntil(jamStartTime)
|
||||
daysUntilJam := daysUntil(hmndata.WRJ2021.StartTime)
|
||||
if daysUntilJam < 0 {
|
||||
daysUntilJam = 0
|
||||
}
|
||||
|
@ -68,7 +200,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
|
|||
}
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
@ -79,7 +211,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
|
|||
showcaseJson := templates.TimelineItemsToJSON(showcaseItems)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
baseData := getBaseDataAutocrumb(c, "Wheel Reinvention Jam")
|
||||
baseData := getBaseDataAutocrumb(c, hmndata.WRJ2021.Name)
|
||||
baseData.OpenGraphItems = []templates.OpenGraphItem{
|
||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||
{Property: "og:type", Value: "website"},
|
||||
|
|
|
@ -30,6 +30,7 @@ type LandingTemplateData struct {
|
|||
MarkAllReadUrl string
|
||||
|
||||
JamUrl string
|
||||
JamDaysUntilStart, JamDaysUntilEnd int
|
||||
}
|
||||
|
||||
func Index(c *RequestContext) ResponseData {
|
||||
|
@ -114,7 +115,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
}
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
@ -150,6 +151,8 @@ func Index(c *RequestContext) ResponseData {
|
|||
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
|
||||
|
||||
JamUrl: hmnurl.BuildJamIndex(),
|
||||
JamDaysUntilStart: daysUntil(hmndata.WRJ2022.StartTime),
|
||||
JamDaysUntilEnd: daysUntil(hmndata.WRJ2022.EndTime),
|
||||
}, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render landing page template"))
|
||||
|
|
|
@ -210,6 +210,7 @@ type ProjectHomepageData struct {
|
|||
ProjectLinks []templates.Link
|
||||
Licenses []templates.Link
|
||||
RecentActivity []templates.TimelineItem
|
||||
SnippetEdit templates.SnippetEdit
|
||||
}
|
||||
|
||||
func ProjectHomepage(c *RequestContext) ResponseData {
|
||||
|
@ -353,13 +354,8 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
))
|
||||
}
|
||||
|
||||
tagId := -1
|
||||
if c.CurrentProject.TagID != nil {
|
||||
tagId = *c.CurrentProject.TagID
|
||||
}
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
Tags: []int{tagId},
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
|
||||
|
@ -369,9 +365,10 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
&s.Snippet,
|
||||
s.Asset,
|
||||
s.DiscordMessage,
|
||||
s.Tags,
|
||||
s.Projects,
|
||||
s.Owner,
|
||||
c.Theme,
|
||||
(c.CurrentUser != nil && (s.Owner.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
|
||||
)
|
||||
item.SmallInfo = true
|
||||
templateData.RecentActivity = append(templateData.RecentActivity, item)
|
||||
|
@ -383,6 +380,25 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
})
|
||||
c.Perf.EndBlock()
|
||||
|
||||
if c.CurrentUser != nil {
|
||||
userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{c.CurrentUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
|
||||
}
|
||||
templateProjects := make([]templates.Project, 0, len(userProjects))
|
||||
for _, p := range userProjects {
|
||||
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||
templateProjects = append(templateProjects, templateProject)
|
||||
}
|
||||
templateData.SnippetEdit = templates.SnippetEdit{
|
||||
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
|
||||
SubmitUrl: hmnurl.BuildSnippetSubmit(),
|
||||
AssetMaxSize: AssetMaxSize(c.CurrentUser),
|
||||
}
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
|
||||
if err != nil {
|
||||
|
@ -422,6 +438,21 @@ func ProjectNew(c *RequestContext) ResponseData {
|
|||
var project templates.ProjectSettings
|
||||
project.Owners = append(project.Owners, templates.UserToTemplate(c.CurrentUser, c.Theme))
|
||||
project.Personal = true
|
||||
|
||||
var currentJam *hmndata.Jam
|
||||
if c.Req.URL.Query().Has("jam") {
|
||||
currentJam = hmndata.CurrentJam()
|
||||
if currentJam != nil {
|
||||
project.JamParticipation = []templates.ProjectJamParticipation{
|
||||
templates.ProjectJamParticipation{
|
||||
JamName: currentJam.Name,
|
||||
JamSlug: currentJam.Slug,
|
||||
Participating: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
||||
BaseData: getBaseDataAutocrumb(c, "New Project"),
|
||||
|
@ -536,6 +567,13 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project jams")
|
||||
projectJams, err := hmndata.FetchJamsForProject(c, c.Conn, c.CurrentUser, p.Project.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jams for project"))
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
lightLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "light")
|
||||
darkLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "dark")
|
||||
|
||||
|
@ -549,6 +587,15 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
|
||||
projectSettings.LinksText = LinksToText(projectLinks)
|
||||
|
||||
projectSettings.JamParticipation = make([]templates.ProjectJamParticipation, 0, len(projectJams))
|
||||
for _, jam := range projectJams {
|
||||
projectSettings.JamParticipation = append(projectSettings.JamParticipation, templates.ProjectJamParticipation{
|
||||
JamName: jam.JamName,
|
||||
JamSlug: jam.JamSlug,
|
||||
Participating: jam.Participating,
|
||||
})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
||||
BaseData: getBaseDataAutocrumb(c, "Edit Project"),
|
||||
|
@ -615,6 +662,7 @@ type ProjectPayload struct {
|
|||
LightLogo FormImage
|
||||
DarkLogo FormImage
|
||||
Tag string
|
||||
JamParticipationSlugs []string
|
||||
|
||||
Slug string
|
||||
Featured bool
|
||||
|
@ -650,7 +698,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
|||
return res
|
||||
}
|
||||
links := ParseLinks(c.Req.Form.Get("links"))
|
||||
description := c.Req.Form.Get("description")
|
||||
description := c.Req.Form.Get("full_description")
|
||||
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||
|
||||
lifecycleStr := c.Req.Form.Get("lifecycle")
|
||||
|
@ -697,6 +745,8 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
|||
return res
|
||||
}
|
||||
|
||||
jamParticipationSlugs := c.Req.Form["jam_participation"]
|
||||
|
||||
res.Payload = ProjectPayload{
|
||||
Name: projectName,
|
||||
Blurb: shortDesc,
|
||||
|
@ -709,6 +759,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
|||
LightLogo: lightLogo,
|
||||
DarkLogo: darkLogo,
|
||||
Tag: tag,
|
||||
JamParticipationSlugs: jamParticipationSlugs,
|
||||
Slug: slug,
|
||||
Personal: !official,
|
||||
Featured: featured,
|
||||
|
@ -911,6 +962,70 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
twitch.UserOrProjectLinksUpdated(twitchLoginsPreChange, twitchLoginsPostChange)
|
||||
}
|
||||
|
||||
// NOTE(asaf): Regular users can only edit the jam participation status of the current jam or
|
||||
// jams the project was previously a part of.
|
||||
var possibleJamSlugs []string
|
||||
if user.IsStaff {
|
||||
possibleJamSlugs = make([]string, 0, len(hmndata.AllJams))
|
||||
for _, jam := range hmndata.AllJams {
|
||||
possibleJamSlugs = append(possibleJamSlugs, jam.Slug)
|
||||
}
|
||||
} else {
|
||||
possibleJamSlugs, err = db.QueryScalar[string](ctx, tx,
|
||||
`
|
||||
SELECT jam_slug
|
||||
FROM jam_project
|
||||
WHERE project_id = $1
|
||||
`,
|
||||
payload.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to fetch jam participation for project")
|
||||
}
|
||||
currentJam := hmndata.CurrentJam()
|
||||
if currentJam != nil {
|
||||
possibleJamSlugs = append(possibleJamSlugs, currentJam.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE jam_project
|
||||
SET participating = FALSE
|
||||
WHERE project_id = $1
|
||||
`,
|
||||
payload.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to remove jam participation for project")
|
||||
}
|
||||
|
||||
for _, jamSlug := range payload.JamParticipationSlugs {
|
||||
found := false
|
||||
for _, possibleSlug := range possibleJamSlugs {
|
||||
if possibleSlug == jamSlug {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO jam_project (project_id, jam_slug, participating)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, jam_slug) DO UPDATE SET
|
||||
participating = EXCLUDED.participating
|
||||
`,
|
||||
payload.ProjectID,
|
||||
jamSlug,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to insert/update jam participation for project")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -56,6 +56,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
|
|||
hmnOnly.GET(hmnurl.RegexWhenIsIt, WhenIsIt)
|
||||
hmnOnly.GET(hmnurl.RegexJamIndex, JamIndex2022)
|
||||
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
|
||||
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
|
||||
hmnOnly.GET(hmnurl.RegexJamFeed2022, JamFeed2022)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
||||
|
||||
|
@ -194,7 +196,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
|
|||
attachProjectRoutes(&officialProjectRoutes)
|
||||
attachProjectRoutes(&personalProjectRoutes)
|
||||
|
||||
// TODO(ben): Uh, should these all be pulled into the project route group above...?
|
||||
anyProject.POST(hmnurl.RegexSnippetSubmit, needsAuth(csrfMiddleware(SnippetEditSubmit)))
|
||||
|
||||
anyProject.GET(hmnurl.RegexEpisodeList, EpisodeList)
|
||||
anyProject.GET(hmnurl.RegexEpisode, Episode)
|
||||
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
||||
|
|
|
@ -23,7 +23,7 @@ func Showcase(c *RequestContext) ResponseData {
|
|||
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,36 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/assets"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/embed"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
type SnippetData struct {
|
||||
templates.BaseData
|
||||
Snippet templates.TimelineItem
|
||||
|
||||
CanEditSnippet bool
|
||||
SnippetEdit templates.SnippetEdit
|
||||
}
|
||||
|
||||
func Snippet(c *RequestContext) ResponseData {
|
||||
|
@ -40,7 +56,9 @@ func Snippet(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
canEdit := (c.CurrentUser != nil && (c.CurrentUser.IsStaff || c.CurrentUser.ID == s.Owner.ID))
|
||||
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, canEdit)
|
||||
snippet.SmallInfo = true
|
||||
|
||||
opengraph := []templates.OpenGraphItem{
|
||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||
|
@ -86,13 +104,290 @@ func Snippet(c *RequestContext) ResponseData {
|
|||
[]templates.Breadcrumb{{Name: snippet.OwnerName, Url: snippet.OwnerUrl}},
|
||||
)
|
||||
baseData.OpenGraphItems = opengraph // NOTE(asaf): We're overriding the defaults on purpose.
|
||||
snippetEdit := templates.SnippetEdit{}
|
||||
if c.CurrentUser != nil {
|
||||
userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{c.CurrentUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
|
||||
}
|
||||
templateProjects := make([]templates.Project, 0, len(userProjects))
|
||||
for _, p := range userProjects {
|
||||
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||
templateProjects = append(templateProjects, templateProject)
|
||||
}
|
||||
snippetEdit = templates.SnippetEdit{
|
||||
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
|
||||
SubmitUrl: hmnurl.BuildSnippetSubmit(),
|
||||
AssetMaxSize: AssetMaxSize(c.CurrentUser),
|
||||
}
|
||||
}
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("snippet.html", SnippetData{
|
||||
BaseData: baseData,
|
||||
Snippet: snippet,
|
||||
CanEditSnippet: canEdit,
|
||||
SnippetEdit: snippetEdit,
|
||||
}, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render snippet template"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func SnippetEditSubmit(c *RequestContext) ResponseData {
|
||||
maxUploadSize := AssetMaxSize(c.CurrentUser)
|
||||
maxBodySize := int64(maxUploadSize + 1024*1024)
|
||||
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
||||
err := c.Req.ParseMultipartForm(maxBodySize)
|
||||
if err != nil {
|
||||
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
||||
}
|
||||
|
||||
form := c.Req.PostForm
|
||||
|
||||
redirect := form.Get("redirect")
|
||||
action := form.Get("action")
|
||||
existingSnippetIdStr := strings.TrimSpace(form.Get("snippet_id"))
|
||||
var existingSnippet *hmndata.SnippetAndStuff
|
||||
originalText := ""
|
||||
var embedUrl *string
|
||||
var assetID *uuid.UUID
|
||||
|
||||
if len(existingSnippetIdStr) > 0 {
|
||||
existingSnippetId, err := strconv.Atoi(existingSnippetIdStr)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse snippet id"))
|
||||
}
|
||||
query := hmndata.SnippetQuery{}
|
||||
if !c.CurrentUser.IsStaff {
|
||||
query.OwnerIDs = []int{c.CurrentUser.ID}
|
||||
}
|
||||
snip, err := hmndata.FetchSnippet(c, c.Conn, c.CurrentUser, existingSnippetId, query)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit"))
|
||||
}
|
||||
}
|
||||
originalText = snip.Snippet.Description
|
||||
embedUrl = snip.Snippet.Url
|
||||
assetID = snip.Snippet.AssetID
|
||||
if snip.Snippet.Url != nil {
|
||||
embedUrl = snip.Snippet.Url
|
||||
}
|
||||
existingSnippet = &snip
|
||||
}
|
||||
|
||||
if strings.ToLower(action) == "delete" {
|
||||
if existingSnippet != nil {
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
DELETE FROM snippet
|
||||
WHERE snippet.id = $1
|
||||
`,
|
||||
existingSnippet.Snippet.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch existing snippet for edit"))
|
||||
}
|
||||
} else {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
} else {
|
||||
if form.Get("remove_attachment") == "true" {
|
||||
embedUrl = nil
|
||||
assetID = nil
|
||||
}
|
||||
text := strings.TrimSpace(form.Get("text"))
|
||||
textHtml := parsing.ParseMarkdown(text, parsing.DiscordMarkdown)
|
||||
projectAssociations := form["project_id"]
|
||||
var assetData *assets.CreateInput
|
||||
|
||||
file, header, err := c.Req.FormFile("file")
|
||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get form file"))
|
||||
}
|
||||
|
||||
if header != nil {
|
||||
content := make([]byte, header.Size)
|
||||
_, err = file.Read(content)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
|
||||
}
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(content)
|
||||
}
|
||||
width := 0
|
||||
height := 0
|
||||
if strings.HasPrefix(contentType, "image/") && contentType != "image/svg+xml" {
|
||||
file.Seek(0, io.SeekStart)
|
||||
config, _, err := image.DecodeConfig(file)
|
||||
if err == nil {
|
||||
width = config.Width
|
||||
height = config.Height
|
||||
}
|
||||
}
|
||||
assetData = &assets.CreateInput{
|
||||
Content: content,
|
||||
Filename: header.Filename,
|
||||
ContentType: contentType,
|
||||
UploaderID: &c.CurrentUser.ID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
}
|
||||
}
|
||||
|
||||
if originalText != text && assetData == nil && embedUrl == nil && assetID == nil {
|
||||
urls := xurls.Relaxed().FindAllString(text, -1)
|
||||
if urls != nil {
|
||||
embeddable, err := embed.GetEmbeddableFromUrls(c, urls, maxUploadSize, time.Second*10, 3)
|
||||
if err != nil {
|
||||
if !errors.Is(err, embed.DownloadTooBigError) && !errors.Is(err, embed.NoEmbedFound) {
|
||||
c.Logger.Error().Err(err).Msg("failed to fetch embeddable for snippet")
|
||||
}
|
||||
} else {
|
||||
if embeddable.Url != "" {
|
||||
embedUrl = &embeddable.Url
|
||||
} else {
|
||||
width := 0
|
||||
height := 0
|
||||
if strings.HasPrefix(embeddable.File.ContentType, "image/") && embeddable.File.ContentType != "image/svg+xml" {
|
||||
reader := bytes.NewReader(embeddable.File.Data)
|
||||
config, _, err := image.DecodeConfig(reader)
|
||||
if err == nil {
|
||||
width = config.Width
|
||||
height = config.Height
|
||||
}
|
||||
}
|
||||
assetData = &assets.CreateInput{
|
||||
Content: embeddable.File.Data,
|
||||
Filename: embeddable.File.Filename,
|
||||
ContentType: embeddable.File.ContentType,
|
||||
UploaderID: &c.CurrentUser.ID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if text == "" && assetData == nil && embedUrl == nil && assetID == nil {
|
||||
return c.RejectRequest("You must provide a description or a file attachment.")
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start transaction"))
|
||||
}
|
||||
defer tx.Rollback(c)
|
||||
|
||||
var asset *models.Asset
|
||||
if assetData != nil {
|
||||
asset, err = assets.Create(c, tx, *assetData)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create asset"))
|
||||
}
|
||||
assetID = &asset.ID
|
||||
}
|
||||
|
||||
snippetId := 0
|
||||
if existingSnippet != nil {
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE snippet SET
|
||||
url = $2,
|
||||
description = $3,
|
||||
_description_html = $4,
|
||||
asset_id = $5,
|
||||
edited_on_website = $6
|
||||
WHERE id = $1
|
||||
`,
|
||||
existingSnippet.Snippet.ID,
|
||||
embedUrl,
|
||||
text,
|
||||
textHtml,
|
||||
assetID,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update snippet"))
|
||||
}
|
||||
snippetId = existingSnippet.Snippet.ID
|
||||
} else {
|
||||
newSnippetId, err := db.QueryOne[int](c, tx,
|
||||
`
|
||||
INSERT INTO snippet (url, "when", description, _description_html, asset_id, owner_id, edited_on_website)
|
||||
VALUES ($1, $2, $3, $4, $5, $6 ,$7)
|
||||
RETURNING id
|
||||
`,
|
||||
embedUrl,
|
||||
time.Now(),
|
||||
text,
|
||||
textHtml,
|
||||
assetID,
|
||||
c.CurrentUser.ID,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to insert snippet"))
|
||||
}
|
||||
snippetId = *newSnippetId
|
||||
}
|
||||
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM snippet_project
|
||||
WHERE snippet_id = $1
|
||||
`,
|
||||
snippetId,
|
||||
)
|
||||
|
||||
if len(projectAssociations) > 0 {
|
||||
var projectIds []int
|
||||
for _, pidStr := range projectAssociations {
|
||||
projId, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
projectIds = append(projectIds, projId)
|
||||
}
|
||||
|
||||
if len(projectIds) > 0 {
|
||||
projectQuery := hmndata.ProjectsQuery{
|
||||
ProjectIDs: projectIds,
|
||||
}
|
||||
if !c.CurrentUser.IsStaff {
|
||||
projectQuery.OwnerIDs = []int{c.CurrentUser.ID}
|
||||
}
|
||||
projects, err := hmndata.FetchProjects(c, tx, c.CurrentUser, projectQuery)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for snippet"))
|
||||
}
|
||||
for _, p := range projects {
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
INSERT INTO snippet_project (snippet_id, project_id, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
snippetId,
|
||||
p.Project.ID,
|
||||
models.SnippetProjectKindWebsite,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
|
||||
}
|
||||
}
|
||||
|
||||
return c.Redirect(redirect, http.StatusSeeOther)
|
||||
}
|
||||
|
|
|
@ -5,8 +5,10 @@ import (
|
|||
"html/template"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
|
@ -64,11 +66,13 @@ func SnippetToTimelineItem(
|
|||
snippet *models.Snippet,
|
||||
asset *models.Asset,
|
||||
discordMessage *models.DiscordMessage,
|
||||
tags []*models.Tag,
|
||||
projects []*hmndata.ProjectAndStuff,
|
||||
owner *models.User,
|
||||
currentTheme string,
|
||||
editable bool,
|
||||
) templates.TimelineItem {
|
||||
item := templates.TimelineItem{
|
||||
ID: strconv.Itoa(snippet.ID),
|
||||
Date: snippet.When,
|
||||
FilterTitle: "Snippets",
|
||||
Url: hmnurl.BuildSnippet(snippet.ID),
|
||||
|
@ -78,8 +82,10 @@ func SnippetToTimelineItem(
|
|||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||
|
||||
Description: template.HTML(snippet.DescriptionHtml),
|
||||
RawDescription: snippet.Description,
|
||||
|
||||
CanShowcase: true,
|
||||
Editable: editable,
|
||||
}
|
||||
|
||||
if asset != nil {
|
||||
|
@ -111,11 +117,11 @@ func SnippetToTimelineItem(
|
|||
item.DiscordMessageUrl = discordMessage.Url
|
||||
}
|
||||
|
||||
sort.Slice(tags, func(i, j int) bool {
|
||||
return tags[i].Text < tags[j].Text
|
||||
sort.Slice(projects, func(i, j int) bool {
|
||||
return projects[i].Project.Name < projects[j].Project.Name
|
||||
})
|
||||
for _, tag := range tags {
|
||||
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
|
||||
for _, proj := range projects {
|
||||
item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj, hmndata.UrlContextForProject(&proj.Project).BuildHomepage(), currentTheme))
|
||||
}
|
||||
|
||||
return item
|
||||
|
|
|
@ -37,6 +37,8 @@ type UserProfileTemplateData struct {
|
|||
|
||||
AdminSetStatusUrl string
|
||||
AdminNukeUrl string
|
||||
|
||||
SnippetEdit templates.SnippetEdit
|
||||
}
|
||||
|
||||
func UserProfile(c *RequestContext) ResponseData {
|
||||
|
@ -148,9 +150,10 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
&s.Snippet,
|
||||
s.Asset,
|
||||
s.DiscordMessage,
|
||||
s.Tags,
|
||||
s.Projects,
|
||||
profileUser,
|
||||
c.Theme,
|
||||
(c.CurrentUser != nil && (profileUser.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
|
||||
)
|
||||
item.SmallInfo = true
|
||||
timelineItems = append(timelineItems, item)
|
||||
|
@ -168,6 +171,15 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
|
||||
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
||||
|
||||
snippetEdit := templates.SnippetEdit{}
|
||||
if c.CurrentUser != nil {
|
||||
snippetEdit = templates.SnippetEdit{
|
||||
AvailableProjectsJSON: templates.SnippetEditProjectsToJSON(templateProjects),
|
||||
SubmitUrl: hmnurl.BuildSnippetSubmit(),
|
||||
AssetMaxSize: AssetMaxSize(c.CurrentUser),
|
||||
}
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
||||
BaseData: baseData,
|
||||
|
@ -183,6 +195,8 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
|
||||
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
|
||||
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
||||
|
||||
SnippetEdit: snippetEdit,
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
|
Reference in New Issue