Compare commits

...

6 Commits

Author SHA1 Message Date
Asaf Gartner a9b0606b79 Use new snippet-project association for jam index showcase 2022-08-06 00:48:56 +03:00
Asaf Gartner 89b1e48e69 Code review 2022-08-06 00:42:08 +03:00
Asaf Gartner 87a146dfa8 Fetch jam snippets by project id 2022-08-06 00:42:08 +03:00
Asaf Gartner efc7d76cb7 Snippet creation and editing 2022-08-06 00:41:37 +03:00
Ben Visness 41c2b6e111 Make slightly more prettier 2022-08-05 00:04:22 -05:00
Ben Visness 1b79f45d71 Make the feed page nicer 2022-08-04 23:22:30 -05:00
38 changed files with 1953 additions and 926 deletions

1
go.mod
View File

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

4
go.sum
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@ -28,7 +28,7 @@ var WRJ2021 = Jam{
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"))),
StartTime: time.Date(2022, 8, 3, 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"))),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,286 @@
{{/*
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>
<script src="{{ static "js/showcase.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>
:root {
--content-background: #f8f8f8;
--card-background: rgba(255, 255, 255, 0.1);
--card-background-hover: rgba(255, 255, 255, 0.16);
}
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;
}
}
</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>

View File

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

View File

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

View File

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

View File

@ -1,280 +1,75 @@
{{/*
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">
<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" }}">
<link rel="stylesheet" href="{{ statictheme .Theme "theme.css" }}" />
{{ template "wheeljam_2022_base.html" . }}
{{ define "content" }}
<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;
margin-top: 0;
}
#dates {
font-variant: small-caps;
font-size: 1.6rem;
margin-top: 0.2rem;
h3.mt0 {
margin-top: 0; /* ugh seriously */
}
#tagline {
font-size: 1rem;
margin-top: 1rem;
line-height: 1.4;
.back-to-normal * {
font-family: "Fira Sans", sans-serif;
}
#top-container a {
color: white !important;
text-decoration: underline;
}
.actions {
margin-top: 1.5rem;
}
.actions a {
text-decoration: none !important;
line-height: 1.4;
.back-to-normal h1,
.back-to-normal h2,
.back-to-normal h3,
.back-to-normal h4,
.back-to-normal h5
{
font-weight: 500;
transition: background-color 50ms ease-in-out;
background-color:rgba(255, 255, 255, 0.1);
margin: 0;
margin-bottom: 0.5rem;
font-size: 1.5rem;
line-height: 1.25em;
}
.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;
}
.showcase-item {
background-color: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.5);
.back-to-normal a {
text-decoration: none;
}
@media screen and (min-width: 30em) {
/* not small styles */
}
#top-container {
margin: 3rem 0;
}
#logo {
width: 31rem;
}
#title {
font-size: 5.2rem;
}
#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;
}
@media screen and (min-width: 30em) {
/* large styles */
}
</style>
</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">
<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="bg-black-20 pt4 pb3 pb4-ns">
<div class="section mw8 margin-center ph3 ph4-l mv4">
<h2>Projects</h2>
<div class="projects">
{{ range .JamProjects }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . "" }}
</div>
{{ end }}
</div>
</div>
</div>
<div class="section mw8 margin-center ph3 ph4-l">
<h2>Recent activity</h2>
<div class="timeline">
{{ range .TimelineItems }}
{{ template "timeline_item.html" . }}
{{ end }}
</div>
</div>
<div class="mw8 margin-center ph3-m ph4-l">
{{ template "footer.html" . }}
<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>
</body>
</html>
<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>
{{ 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 }}

View File

@ -1,512 +1,232 @@
{{/*
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" }}">
{{ define "content" }}
{{ $discordInviteURL := "https://discord.gg/zFt8Rf59?event=1004511448107602031" }}
{{ 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>
<script src="{{ static "js/showcase.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>
: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 !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;
}
}
</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>
{{ $discordInviteURL := "https://discord.gg/zFt8Rf59?event=1004511448107602031" }}
<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.
{{ if gt .DaysUntilEnd 0 }}
{{ if eq .DaysUntilStart 0 }}
<b>Happening now.</b>
{{ else if eq .DaysUntilStart 1 }}
<b>Starting tomorrow.</b>
{{ else }}
<b>In {{ .DaysUntilStart }} days.</b>
{{ end }}
{{ 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">Choose a project</a>
<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.
{{ if gt .DaysUntilEnd 0 }}
{{ if eq .DaysUntilStart 0 }}
<b>Happening now.</b>
{{ else if eq .DaysUntilStart 1 }}
<b>Starting tomorrow.</b>
{{ 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" target="_blank" href="https://github.com/HandmadeNetwork/wishlist/discussions">Choose a project</a>
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ .ProjectSubmissionUrl }}">Create a jam project</a>
{{ end }}
<b>In {{ .DaysUntilStart }} days.</b>
{{ end }}
{{ 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 }}
{{ end }}
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ $discordInviteURL }}">Join the Discord</a>
</div>
{{ end }}
<a class="ba b--white br2 pv2 pv3-ns ph3 ph4-ns ml3" target="_blank" href="{{ $discordInviteURL }}">Join the Discord</a>
</div>
</div>
<div class="section mw8 margin-center ph3 ph4-l mv4">
<p>
The <strong>Wheel Reinvention Jam</strong> is a one-week-long jam where we turn a fresh eye to "solved problems".
</p>
<p>
The tools we use every day are broken. Software is slow, unreliable, and bloated with thoughtless features. It <a href="https://twitter.com/ryanjfleury/status/1537278864111464448" target="_blank">disrespects the user</a> and forces settings that <a href="https://twitter.com/ra/status/1151988912845234178" target="_blank">no one wants</a>. And yet, people defend the status quo, claiming that what we have is fine, and that trying to change software is "reinventing the wheel".
</p>
<p>
Screw that. Progress is only made by inventing new things. It's not "reinventing" to break new ground. Nor is it "reinventing" to take a broken thing and design something better.
</p>
<p>
This is your chance to reinvent something.
</p>
</div>
<div class="section mw8 margin-center ph3 ph4-l mv4">
<p>
The <strong>Wheel Reinvention Jam</strong> is a one-week-long jam where we turn a fresh eye to "solved problems".
</p>
<p>
The tools we use every day are broken. Software is slow, unreliable, and bloated with thoughtless features. It <a href="https://twitter.com/ryanjfleury/status/1537278864111464448" target="_blank">disrespects the user</a> and forces settings that <a href="https://twitter.com/ra/status/1151988912845234178" target="_blank">no one wants</a>. And yet, people defend the status quo, claiming that what we have is fine, and that trying to change software is "reinventing the wheel".
</p>
<p>
Screw that. Progress is only made by inventing new things. It's not "reinventing" to break new ground. Nor is it "reinventing" to take a broken thing and design something better.
</p>
<p>
This is your chance to reinvent something.
</p>
</div>
{{ if .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!
</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 more</a>
</div>
{{ if 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!
</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 more</a>
</div>
</div>
{{ end }}
</div>
{{ else }}
<div class="section bg-black-20 pv4 overflow-hidden">
<div class="mw8 margin-center ph3 ph4-l">
<h2>Last year's entries</h2>
<p>
We had many incredible entries last year. Here are a few of our favorites:
</p>
{{ if gt .DaysUntilStart 0 }}
<div class="section bg-black-20 pv4 overflow-hidden">
<div class="mw8 margin-center ph3 ph4-l">
<h2>Last year's entries</h2>
<p>
We had many incredible entries last year. Here are a few of our favorites:
</p>
<div class="carousel-container">
<div class="carousel bg-white-10 br3 pa3 pa4-ns">
<div class="carousel-item active">
<img class="br2" src="{{ static "wheeljam2022/scroll.png" }}">
<h3>Scroll</h3>
<p>
Scroll is an experimental new typesetting format and editor. The document structure is inherently non-textual; in fact, even words within paragraphs are individual nodes that can easily be selected and moved as a whole. It's a great proof-of-concept of what "word processors" could be—and it even has a PDF export.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8116-jam_submition_-_scroll%252C_a_experiment_in_a_non_text_typesetting_file_format">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=1083" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/nearmanager.gif" }}">
<h3>Near</h3>
<p>
Near (or Near Manager) is an experimental file viewer that breaks away from a plain hierarchy. By allowing you to flatten folder hierarchies, create custom groups, and reorder your files, Near allows you to tame any complex file structure and view it in a way that works for you.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8120-jam_submission_-_near%252C_a_file_explorer_with_interesting_ideas">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=435" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/visaviz.png" }}">
<h3>Twitter Thread Graph Explorer</h3>
<p>
This project extended an existing personal project with a unique way of exploring Twitter threads. When the author found existing layout algorithms insufficient, he decided to roll his own. The project submission is an insightful look at why you sometimes need to do things yourself.
</p>
<p>
This project was featured as a demo at Handmade Seattle 2021.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8137-jam_submission_-_twitter_thread_graph_explorer">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=7519" target="_blank">Recap Interview ➜</a>
<a class="b db" href="https://media.handmade-seattle.com/visa-viz/" target="_blank">Handmade Seattle Demo ➜</a>
</div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/databaseexplorer.png" }}">
<h3>Database Explorer</h3>
<p>
This project explores a new way of querying SQL databases, by throwing away SQL in favor of a visual node graph. It allows you to incrementally build queries, seeing the data at every point along the way, and to reuse smaller queries in a way SQL cannot.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8127-jam_submission__database_explorer">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=6390" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-thinger next"></div>
<div class="carousel-thinger prev"></div>
<div class="carousel-container">
<div class="carousel bg-white-10 br3 pa3 pa4-ns">
<div class="carousel-item active">
<img class="br2" src="{{ static "wheeljam2022/scroll.png" }}">
<h3>Scroll</h3>
<p>
Scroll is an experimental new typesetting format and editor. The document structure is inherently non-textual; in fact, even words within paragraphs are individual nodes that can easily be selected and moved as a whole. It's a great proof-of-concept of what "word processors" could be—and it even has a PDF export.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8116-jam_submition_-_scroll%252C_a_experiment_in_a_non_text_typesetting_file_format">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=1083" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-buttons mt2 pv2"></div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/nearmanager.gif" }}">
<h3>Near</h3>
<p>
Near (or Near Manager) is an experimental file viewer that breaks away from a plain hierarchy. By allowing you to flatten folder hierarchies, create custom groups, and reorder your files, Near allows you to tame any complex file structure and view it in a way that works for you.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8120-jam_submission_-_near%252C_a_file_explorer_with_interesting_ideas">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=435" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/visaviz.png" }}">
<h3>Twitter Thread Graph Explorer</h3>
<p>
This project extended an existing personal project with a unique way of exploring Twitter threads. When the author found existing layout algorithms insufficient, he decided to roll his own. The project submission is an insightful look at why you sometimes need to do things yourself.
</p>
<p>
This project was featured as a demo at Handmade Seattle 2021.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8137-jam_submission_-_twitter_thread_graph_explorer">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=7519" target="_blank">Recap Interview ➜</a>
<a class="b db" href="https://media.handmade-seattle.com/visa-viz/" target="_blank">Handmade Seattle Demo ➜</a>
</div>
<div class="carousel-item">
<img class="br2" src="{{ static "wheeljam2022/databaseexplorer.png" }}">
<h3>Database Explorer</h3>
<p>
This project explores a new way of querying SQL databases, by throwing away SQL in favor of a visual node graph. It allows you to incrementally build queries, seeing the data at every point along the way, and to reuse smaller queries in a way SQL cannot.
</p>
<a class="b db" href="https://handmade.network/forums/jam/t/8127-jam_submission__database_explorer">Full Submission ➜</a>
<a class="b db" href="https://youtu.be/1RjU5XJqysc?t=6390" target="_blank">Recap Interview ➜</a>
</div>
<div class="carousel-thinger next"></div>
<div class="carousel-thinger prev"></div>
</div>
</div>
</div>
{{ end }}
<div class="pt4 pb3 pb4-ns">
<div class="section mw8 margin-center ph3 ph4-l">
<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! 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>
{{ if eq .DaysUntilStart 0 }}
<a href="{{ or .SubmittedProjectUrl .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>
<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>
{{ if and (eq .DaysUntilStart 0) (gt .DaysUntilEnd 0) }}
<p>
Submissions close <b><span class="countdown" data-deadline="{{ .EndTimeUnix }}"></span></b>.
</p>
{{ else if eq .DaysUntilEnd 0 }}
<p>
<b>Submissions are now closed.</b>
</p>
{{ end }}
<div class="carousel-buttons mt2 pv2"></div>
</div>
</div>
</div>
{{ end }}
<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>
<div class="pt4 pb3 pb4-ns">
<div class="section mw8 margin-center ph3 ph4-l">
<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>
<b>{{ with .SubmittedProjectUrl }}
<a href="{{ . }}" target="_blank">Your Handmade Network project</a>
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>
{{ if eq .DaysUntilStart 0 }}
<a href="{{ or .SubmittedProjectUrl .ProjectSubmissionUrl }}" target="_blank"><b>Create a Handmade Network project</b></a>
{{ else }}
Your Handmade Network project
After the jam starts, create a 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.
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>
<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>
<p>
The Handmade ethos and Handmade community are software development superpowers. Don't be afraid to question your foundations and rebuild what needs rebuilding. The community is here to help you take on those challenges and do what others might consider impossible.
</p>
<p>
Of course, this is a jam, so focus on what matters to your project. There are many excellent libraries in the community that can save you time and help you focus on your core ideas. Don't be afraid to use them. But don't be afraid to do your own thing if they're holding you back.
</p>
</div>
<div class="section flex-fair ml4-m ml5-l">
<h2>Don't just rebuild. Reinvent.</h2>
<p>
This is a chance to build something <em>truly new</em>. Learn from previous work, but don't settle for “the same, but better”. It would be a huge shame to spend a week building nothing more than a clone of the same broken software we use today.
</p>
<p>
This is where working with a team can really help. Bounce ideas off each other, do some research, and brainstorm before the jam starts. The software you end up building might be pretty different from your original ideas.
</p>
<p>
In the end, this is a jam. Get weird and try something different.
</p>
</div>
</div>
</div>
<div class="mw8 margin-center ph3-m ph4-l">
{{ template "footer.html" . }}
<div class="{{ if eq .DaysUntilEnd 1 }}emphasized{{ end }}">
<h3>Submit your work!</h3>
<p>
<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>
{{ if and (eq .DaysUntilStart 0) (gt .DaysUntilEnd 0) }}
<p>
Submissions close <b><span class="countdown" data-deadline="{{ .EndTimeUnix }}"></span></b>.
</p>
{{ else if eq .DaysUntilEnd 0 }}
<p>
<b>Submissions are now closed.</b>
</p>
{{ 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>
<p>
The Handmade ethos and Handmade community are software development superpowers. Don't be afraid to question your foundations and rebuild what needs rebuilding. The community is here to help you take on those challenges and do what others might consider impossible.
</p>
<p>
Of course, this is a jam, so focus on what matters to your project. There are many excellent libraries in the community that can save you time and help you focus on your core ideas. Don't be afraid to use them. But don't be afraid to do your own thing if they're holding you back.
</p>
</div>
<div class="section flex-fair ml4-m ml5-l">
<h2>Don't just rebuild. Reinvent.</h2>
<p>
This is a chance to build something <em>truly new</em>. Learn from previous work, but don't settle for “the same, but better”. It would be a huge shame to spend a week building nothing more than a clone of the same broken software we use today.
</p>
<p>
This is where working with a team can really help. Bounce ideas off each other, do some research, and brainstorm before the jam starts. The software you end up building might be pretty different from your original ideas.
</p>
<p>
In the end, this is a jam. Get weird and try something different.
</p>
</div>
</div>
</div>
@ -522,7 +242,7 @@
const showcaseOuterContainer = document.querySelector('#showcase-outer-container');
let showcaseContainer = document.querySelector('#showcase-container');
showcaseOuterContainer.classList.toggle('dn', showcaseItems.length === 0);
// showcaseOuterContainer.classList.toggle('dn', showcaseItems.length === 0);
const itemElements = []; // array of arrays
for (let i = 0; i < showcaseItems.length; i++) {
@ -663,6 +383,4 @@
setInterval(updateCountdown, 1000 * 60);
}
</script>
</body>
</html>
{{ end }}

View File

@ -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,9 @@ var HMNTemplateFuncs = template.FuncMap{
}
return fmt.Sprintf("%.*f%s", precision, num, scales[scale])
},
"cleancontrolchars": func(str template.HTML) template.HTML {
return template.HTML(controlCharRegex.ReplaceAllString(string(str), ""))
},
// NOTE(asaf): Template specific functions:
"projectcarddata": func(project Project, classes string) ProjectCardData {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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