diff --git a/public/js/image_selector.js b/public/js/image_selector.js new file mode 100644 index 00000000..88ff332c --- /dev/null +++ b/public/js/image_selector.js @@ -0,0 +1,97 @@ +function ImageSelector(form, maxFileSize, container) { + this.form = form; + this.maxFileSize = maxFileSize; + this.fileInput = container.querySelector(".image_input"); + this.removeImageInput = container.querySelector(".remove_input"); + this.imageEl = container.querySelector("img"); + this.resetLink = container.querySelector(".reset"); + this.removeLink = container.querySelector(".remove"); + this.originalImageUrl = this.imageEl.getAttribute("data-original"); + this.currentImageUrl = this.originalImageUrl; + + this.fileInput.value = ""; + this.removeImageInput.value = ""; + + this.setImageUrl(this.originalImageUrl); + this.updateButtons(); + + this.fileInput.addEventListener("change", function(ev) { + if (this.fileInput.files.length > 0) { + this.handleNewImageFile(this.fileInput.files[0]); + } + }.bind(this)); + + this.resetLink.addEventListener("click", function(ev) { + this.resetImage(); + }.bind(this)); + + if (this.removeLink) { + this.removeLink.addEventListener("click", function(ev) { + this.removeImage(); + }.bind(this)); + } +} + +ImageSelector.prototype.handleNewImageFile = function(file) { + if (file) { + this.updateSizeLimit(file.size); + this.removeImageInput.value = ""; + this.setImageUrl(URL.createObjectURL(file)); + this.updateButtons(); + } +}; + +ImageSelector.prototype.removeImage = function() { + this.updateSizeLimit(0); + this.fileInput.value = ""; + this.removeImageInput.value = "true"; + this.setImageUrl(""); + this.updateButtons(); +}; + +ImageSelector.prototype.resetImage = function() { + this.updateSizeLimit(0); + this.fileInput.value = ""; + this.removeImageInput.value = ""; + this.setImageUrl(this.originalImageUrl); + this.updateButtons(); +}; + +ImageSelector.prototype.updateSizeLimit = function(size) { + this.fileTooBig = size > this.maxFileSize; + if (this.fileTooBig) { + this.setError("File too big. Max filesize is " + this.maxFileSize + " bytes."); + } else { + this.setError(""); + } +}; + +ImageSelector.prototype.setError = function(error) { + this.fileInput.setCustomValidity(error); + this.fileInput.reportValidity(); +} + +ImageSelector.prototype.setImageUrl = function(url) { + this.currentImageUrl = url; + this.imageEl.src = url; + if (url.length > 0) { + this.imageEl.style.display = "block"; + } else { + this.imageEl.style.display = "none"; + } +}; + +ImageSelector.prototype.updateButtons = function() { + if (this.originalImageUrl.length > 0 && this.currentImageUrl != this.originalImageUrl) { + this.resetLink.style.display = "inline-block"; + } else { + this.resetLink.style.display = "none"; + } + + if (!this.fileInput.required && this.currentImageUrl != "") { + this.removeLink.style.display = "inline-block"; + } else { + this.removeLink.style.display = "none"; + } +}; + diff --git a/public/js/tabs.js b/public/js/tabs.js index 2db85bdd..0440425d 100644 --- a/public/js/tabs.js +++ b/public/js/tabs.js @@ -91,4 +91,16 @@ function switchTab(container, slug) { } window.location.hash = slug; -} \ No newline at end of file +} + +function switchToTabOfElement(container, el) { + const tabs = Array.from(container.querySelectorAll('.tab')); + let target = el.parentElement; + while (target) { + if (tabs.includes(target)) { + switchTab(container, target.getAttribute("data-slug")); + return; + } + target = target.parentElement; + } +} diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 1d09de3f..d88d1e48 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -99,6 +99,61 @@ func init() { } adminCommand.AddCommand(activateUserCommand) + userStatusCommand := &cobra.Command{ + Use: "userstatus [username] [status]", + Short: "Set a user's status manually", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 2 { + fmt.Printf("You must provide a username and status.\n\n") + fmt.Printf("Statuses:\n") + fmt.Printf("1. inactive:\n") + fmt.Printf("2. confirmed:\n") + fmt.Printf("3. approved:\n") + fmt.Printf("4. banned:\n") + cmd.Usage() + os.Exit(1) + } + + username := args[0] + statusStr := args[1] + status := models.UserStatusInactive + switch statusStr { + case "inactive": + status = models.UserStatusInactive + case "confirmed": + status = models.UserStatusConfirmed + case "approved": + status = models.UserStatusApproved + case "banned": + status = models.UserStatusBanned + default: + fmt.Printf("You must provide a valid status\n\n") + fmt.Printf("Statuses:\n") + fmt.Printf("1. inactive:\n") + fmt.Printf("2. confirmed:\n") + fmt.Printf("3. approved:\n") + fmt.Printf("4. banned:\n") + cmd.Usage() + os.Exit(1) + } + + ctx := context.Background() + conn := db.NewConnPool(1, 1) + defer conn.Close() + + res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username) + if err != nil { + panic(err) + } + if res.RowsAffected() == 0 { + fmt.Printf("User not found.\n\n") + } + + fmt.Printf("%s is now %s\n\n", username, statusStr) + }, + } + adminCommand.AddCommand(userStatusCommand) + sendTestMailCommand := &cobra.Command{ Use: "sendtestmail [type] [toAddress] [toName]", Short: "Sends a test mail", diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 4ba4461a..2832c92b 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -302,6 +302,10 @@ func TestEditorPreviewsJS(t *testing.T) { AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil) } +func TestAPICheckUsername(t *testing.T) { + AssertRegexmatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil) +} + func TestPublic(t *testing.T) { AssertRegexMatch(t, BuildPublic("test", false), RegexPublic, nil) AssertRegexMatch(t, BuildPublic("/test", true), RegexPublic, nil) diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index a31552d8..dfb8cc96 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -284,12 +284,12 @@ func BuildProjectIndex(page int) string { } } -var RegexProjectNew = regexp.MustCompile("^/projects/new$") +var RegexProjectNew = regexp.MustCompile("^/p/new$") func BuildProjectNew() string { defer CatchPanic() - return Url("/projects/new", nil) + return Url("/p/new", nil) } var RegexPersonalProject = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[a-zA-Z0-9-]+))?") @@ -670,6 +670,16 @@ func BuildDiscordShowcaseBacklog() string { return Url("/discord_showcase_backlog", nil) } +/* +* API + */ + +var RegexAPICheckUsername = regexp.MustCompile("^/api/check_username$") + +func BuildAPICheckUsername() string { + return Url("/api/check_username", nil) +} + /* * User assets */ diff --git a/src/templates/src/include/image_selector.html b/src/templates/src/include/image_selector.html new file mode 100644 index 00000000..ad1c73c0 --- /dev/null +++ b/src/templates/src/include/image_selector.html @@ -0,0 +1,11 @@ +{{/* NOTE(asaf): Make sure to include js/image_selector.js */}} + + + +
+ +
+ Reset + Remove +
+
diff --git a/src/templates/src/project_edit.html b/src/templates/src/project_edit.html new file mode 100644 index 00000000..1e27cb4c --- /dev/null +++ b/src/templates/src/project_edit.html @@ -0,0 +1,287 @@ +{{ template "base.html" . }} + +{{ define "extrahead" }} + + + +{{ end }} + +{{ define "content" }} +
+ {{ if .Editing }} +

Edit {{ .ProjectSettings.Name }}

+ {{ else }} +

Create a new project

+ {{ end }} +
+ {{ csrftoken .Session }} +
+
+
Project name:
+
+ + * Required +
+
+
+
Status:
+
+ +
+
+
+ {{/* TODO(asaf): Should this be admin only??*/}} +
Hidden:
+
+ + +
+
+
+
Owners:
+
+ + Add + +
+ + {{ range .ProjectSettings.Owners }} +
+ + {{ .Username }} + {{ if ne .ID $.User.ID }} + X + {{ end }} +
+ {{ end }} +
+
+
+ {{ if .User.IsStaff }} +
+
Admin settings
+
+
+
Slug:
+
+ +
+
+
+
Official:
+
+ + +
+
+
+
Featured:
+
+ + +
Bump to the top of the project index and show in the carousel.
+
+
+ {{ end }} +
+
+
+ {{ if .Editing }} + + {{ else }} + Next + {{ end }} +
+
+
+
+
+
Short description:
+
+ +
Plaintext only. No links or markdown.
+
+
+
+
Full description:
+
+ + {{/* TODO(asaf): Replace with the full editor */}} +
+
+
+
+
+ {{ if .Editing }} + + {{ else }} + Next + {{ end }} +
+
+
+
+
+
Light theme logo:
+ +
+
+
Dark theme logo:
+ +
+
+
+
+ {{ if .Editing }} + + {{ else }} + + {{ end }} +
+
+
+
+
+ +{{ end }} diff --git a/src/templates/src/user_profile.html b/src/templates/src/user_profile.html index 7f15891e..256a8cbc 100644 --- a/src/templates/src/user_profile.html +++ b/src/templates/src/user_profile.html @@ -36,31 +36,33 @@
- {{ with .ProfileUserProjects }} + {{ if or .OwnProfile .ProfileUserProjects }}
-

Projects

- {{ range . }} +

{{ if .OwnProfile }}My {{ end }}Projects

+ {{ range .ProfileUserProjects }}
{{ template "project_card.html" projectcarddata . "" }}
{{ end }} + {{ if .OwnProfile }} + + New Project + {{ end }} +
+ {{ end }} + {{ if eq 1 0 }} +
+

Add Snippets

+
+ Show us what you're working on.
+ You can upload videos, images, and audio clips.
+ Your snippets may appear on the showcase page. +
{{ end }} {{ if gt (len .TimelineItems) 0 }} -
+

Recent Activity

- {{/* - {{ if gt .NumForums 0 }} -
- {{ end }} - {{ if gt .NumBlogs 0 }} -
- {{ end }} - {{ if gt .NumSnippets 0 }} -
- {{ end }} - */}}
{{ range .TimelineItems }} diff --git a/src/templates/templates.go b/src/templates/templates.go index 920de569..7be682ac 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -110,6 +110,9 @@ var HMNTemplateFuncs = template.FuncMap{ "csrftoken": func(s Session) template.HTML { return template.HTML(fmt.Sprintf(``, auth.CSRFFieldName, s.CSRFToken)) }, + "csrftokenjs": func(s Session) template.HTML { + return template.HTML(fmt.Sprintf(`{ "field": "%s", "token": "%s" }`, auth.CSRFFieldName, s.CSRFToken)) + }, "darken": func(amount float64, color noire.Color) noire.Color { return color.Shade(amount) }, @@ -199,6 +202,14 @@ var HMNTemplateFuncs = template.FuncMap{ } }, + "imageselectordata": func(name string, src string, required bool) ImageSelectorData { + return ImageSelectorData{ + Name: name, + Src: src, + Required: required, + } + }, + "mediaimage": func() TimelineItemMediaType { return TimelineItemMediaTypeImage }, "mediavideo": func() TimelineItemMediaType { return TimelineItemMediaTypeVideo }, "mediaaudio": func() TimelineItemMediaType { return TimelineItemMediaTypeAudio }, diff --git a/src/templates/types.go b/src/templates/types.go index 5c86f581..148276e8 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -133,6 +133,22 @@ type Project struct { DateApproved time.Time } +type ProjectSettings struct { + Name string + Slug string + Hidden bool + Featured bool + Personal bool + Lifecycle string + + Blurb string + Description string + Owners []User + + LightLogo string + DarkLogo string +} + type User struct { ID int Username string @@ -304,6 +320,12 @@ type ProjectCardData struct { Classes string } +type ImageSelectorData struct { + Name string + Src string + Required bool +} + type Breadcrumb struct { Name, Url string Current bool diff --git a/src/website/api.go b/src/website/api.go new file mode 100644 index 00000000..63fc126c --- /dev/null +++ b/src/website/api.go @@ -0,0 +1,54 @@ +package website + +import ( + "errors" + "fmt" + "net/http" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" +) + +func APICheckUsername(c *RequestContext) ResponseData { + c.Req.ParseForm() + usernameArgs, hasUsername := c.Req.Form["username"] + found := false + canonicalUsername := "" + if hasUsername { + requestedUsername := usernameArgs[0] + found = true + c.Perf.StartBlock("SQL", "Fetch user") + userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + ` + SELECT $columns + FROM + auth_user + WHERE + LOWER(auth_user.username) = LOWER($1) + AND status = ANY ($2) + `, + requestedUsername, + []models.UserStatus{models.UserStatusConfirmed, models.UserStatusApproved}, + ) + c.Perf.EndBlock() + if err != nil { + if errors.Is(err, db.NotFound) { + found = false + } else { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername)) + } + } else { + canonicalUsername = userResult.(*models.User).Username + } + } + + var res ResponseData + res.Header().Set("Content-Type", "application/json") + if found { + res.Write([]byte(fmt.Sprintf(`{ "found": true, "canonical": "%s" }`, canonicalUsername))) + } else { + res.Write([]byte(`{ "found": false }`)) + } + return res +} diff --git a/src/website/projects.go b/src/website/projects.go index 072a02c0..7855e8f2 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -362,3 +362,53 @@ func ProjectHomepage(c *RequestContext) ResponseData { } return res } + +var ProjectLogoMaxFileSize = 2 * 1024 * 1024 + +type ProjectEditData struct { + templates.BaseData + + Editing bool + ProjectSettings templates.ProjectSettings + + APICheckUsernameUrl string + LogoMaxFileSize int +} + +func ProjectNew(c *RequestContext) ResponseData { + var project templates.ProjectSettings + project.Owners = append(project.Owners, templates.UserToTemplate(c.CurrentUser, c.Theme)) + project.Personal = true + var res ResponseData + res.MustWriteTemplate("project_edit.html", ProjectEditData{ + BaseData: getBaseDataAutocrumb(c, "New Project"), + Editing: false, + ProjectSettings: project, + + APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(), + LogoMaxFileSize: ProjectLogoMaxFileSize, + }, c.Perf) + return res +} + +func ProjectNewSubmit(c *RequestContext) ResponseData { + return FourOhFour(c) +} + +func ProjectEdit(c *RequestContext) ResponseData { + // Find project + // Convert to template + var project templates.ProjectSettings + + var res ResponseData + res.MustWriteTemplate("project_edit.html", ProjectEditData{ + BaseData: getBaseDataAutocrumb(c, "Edit Project"), + Editing: true, + ProjectSettings: project, + }, c.Perf) + return res +} + +func ProjectEditSubmit(c *RequestContext) ResponseData { + return FourOhFour(c) +} diff --git a/src/website/routes.go b/src/website/routes.go index 68e4d0a6..c652a9bc 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -196,6 +196,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht hmnOnly.GET(hmnurl.RegexSnippet, Snippet) hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex) + hmnOnly.GET(hmnurl.RegexProjectNew, authMiddleware(ProjectNew)) + hmnOnly.POST(hmnurl.RegexProjectNew, authMiddleware(csrfMiddleware(ProjectNewSubmit))) + hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback)) hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink))) hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog))) @@ -214,6 +217,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht hmnOnly.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode) hmnOnly.GET(hmnurl.RegexPodcastRSS, PodcastRSS) + hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername)) + hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet) attachProjectRoutes := func(rb *RouteBuilder) { @@ -225,6 +230,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht } }) + rb.GET(hmnurl.RegexProjectEdit, authMiddleware(ProjectEdit)) + rb.POST(hmnurl.RegexProjectEdit, authMiddleware(csrfMiddleware(ProjectEditSubmit))) + // Middleware used for forum action routes - anything related to actually creating or editing forum content needsForums := func(h Handler) Handler { return func(c *RequestContext) ResponseData { diff --git a/src/website/user.go b/src/website/user.go index fa00eccc..56083fac 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -26,6 +26,9 @@ type UserProfileTemplateData struct { ProfileUserLinks []templates.Link ProfileUserProjects []templates.Project TimelineItems []templates.TimelineItem + OwnProfile bool + ShowcaseUrl string + NewProjectUrl string } func UserProfile(c *RequestContext) ResponseData { @@ -194,6 +197,9 @@ func UserProfile(c *RequestContext) ResponseData { ProfileUserLinks: profileUserLinks, ProfileUserProjects: templateProjects, TimelineItems: timelineItems, + OwnProfile: c.CurrentUser.ID == profileUser.ID, + ShowcaseUrl: hmnurl.BuildShowcase(), + NewProjectUrl: hmnurl.BuildProjectNew(), }, c.Perf) return res }