New project form
This commit is contained in:
parent
0f749d8232
commit
03c82c9d1a
|
@ -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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -92,3 +92,15 @@ function switchTab(container, slug) {
|
||||||
|
|
||||||
window.location.hash = slug;
|
window.location.hash = slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -99,6 +99,61 @@ func init() {
|
||||||
}
|
}
|
||||||
adminCommand.AddCommand(activateUserCommand)
|
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{
|
sendTestMailCommand := &cobra.Command{
|
||||||
Use: "sendtestmail [type] [toAddress] [toName]",
|
Use: "sendtestmail [type] [toAddress] [toName]",
|
||||||
Short: "Sends a test mail",
|
Short: "Sends a test mail",
|
||||||
|
|
|
@ -302,6 +302,10 @@ func TestEditorPreviewsJS(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil)
|
AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPICheckUsername(t *testing.T) {
|
||||||
|
AssertRegexmatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublic(t *testing.T) {
|
func TestPublic(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildPublic("test", false), RegexPublic, nil)
|
AssertRegexMatch(t, BuildPublic("test", false), RegexPublic, nil)
|
||||||
AssertRegexMatch(t, BuildPublic("/test", true), RegexPublic, nil)
|
AssertRegexMatch(t, BuildPublic("/test", true), RegexPublic, nil)
|
||||||
|
|
|
@ -284,12 +284,12 @@ func BuildProjectIndex(page int) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectNew = regexp.MustCompile("^/projects/new$")
|
var RegexProjectNew = regexp.MustCompile("^/p/new$")
|
||||||
|
|
||||||
func BuildProjectNew() string {
|
func BuildProjectNew() string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
|
|
||||||
return Url("/projects/new", nil)
|
return Url("/p/new", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<projectslug>[a-zA-Z0-9-]+))?")
|
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<projectslug>[a-zA-Z0-9-]+))?")
|
||||||
|
@ -670,6 +670,16 @@ func BuildDiscordShowcaseBacklog() string {
|
||||||
return Url("/discord_showcase_backlog", nil)
|
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
|
* User assets
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{/* NOTE(asaf): Make sure to include js/image_selector.js */}}
|
||||||
|
<input {{ if .Required }}required{{ end }} class="image_input" type="file" accept="image/*" name="{{ .Name }}" />
|
||||||
|
<input class="remove_input" type="hidden" name="remove_{{ .Name }}" />
|
||||||
|
<span class="error dn notice notice-failure mv2 mw6"></span>
|
||||||
|
<div class="image_container">
|
||||||
|
<img class="mw6" data-original="{{ .Src }}" src="{{ .Src }}" />
|
||||||
|
<div >
|
||||||
|
<a href="javascript:;" class="reset">Reset</a>
|
||||||
|
<a href="javascript:;" class="remove">Remove</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,287 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<script src="{{ static "js/tabs.js" }}"></script>
|
||||||
|
<script src="{{ static "js/image_selector.js" }}"></script>
|
||||||
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="content-block">
|
||||||
|
{{ if .Editing }}
|
||||||
|
<h1>Edit {{ .ProjectSettings.Name }}</h1>
|
||||||
|
{{ else }}
|
||||||
|
<h1>Create a new project</h1>
|
||||||
|
{{ end }}
|
||||||
|
<form id="project_form" class="tabbed edit-form" method="POST" enctype="multipart/form-data">
|
||||||
|
{{ csrftoken .Session }}
|
||||||
|
<div class="tab" data-name="General" data-slug="general">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Project name:</div>
|
||||||
|
<div>
|
||||||
|
<input required type="text" name="project_name" maxlength="255" class="textbox" value="{{ .ProjectSettings.Name }}">
|
||||||
|
<span class="note">* Required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Status:</div>
|
||||||
|
<div>
|
||||||
|
<select name="lifecycle">
|
||||||
|
<option value="active" {{ if eq .ProjectSettings.Lifecycle "active" }}selected{{ end }}>Active</option>
|
||||||
|
<option value="hiatus" {{ if eq .ProjectSettings.Lifecycle "hiatus" }}selected{{ end }}>Hiatus</option>
|
||||||
|
<option value="done" {{ if eq .ProjectSettings.Lifecycle "done" }}selected{{ end }}>Completed</option>
|
||||||
|
<option value="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
{{/* TODO(asaf): Should this be admin only??*/}}
|
||||||
|
<div>Hidden:</div>
|
||||||
|
<div>
|
||||||
|
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
|
||||||
|
<label for="hidden">Hide</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Owners:</div>
|
||||||
|
<div>
|
||||||
|
<input id="owner_name" form="" type="text" placeholder="Enter another user's username" />
|
||||||
|
<a href="javascript:;" id="owner_add" class="">Add</a>
|
||||||
|
<span id="owners_error" class="note"></span>
|
||||||
|
<div id="owner_list" class="pt1">
|
||||||
|
<template id="owner_row">
|
||||||
|
<div class="owner_row flex flex-row bg--card w5 pv1 ph2" data-tmpl="root">
|
||||||
|
<input type="hidden" name="owners" data-tmpl="input" />
|
||||||
|
<span class="flex-grow-1" data-tmpl="name"></span>
|
||||||
|
<a class="remove_owner" href="javascript:;">X</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ range .ProjectSettings.Owners }}
|
||||||
|
<div class="owner_row flex flex-row bg--card w5 pv1 ph2">
|
||||||
|
<input type="hidden" name="owners" value="{{ .Username }}" />
|
||||||
|
<span class="flex-grow-1">{{ .Username }}</span>
|
||||||
|
{{ if ne .ID $.User.ID }}
|
||||||
|
<a class="remove_owner" href="javascript:;">X</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .User.IsStaff }}
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Admin settings</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Slug:</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Official:</div>
|
||||||
|
<div>
|
||||||
|
<input id="official" type="checkbox" name="official" {{ if not .ProjectSettings.Personal }}checked{{ end }} />
|
||||||
|
<label for="official">Official HMN project</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Featured:</div>
|
||||||
|
<div>
|
||||||
|
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
|
||||||
|
<label for="featured">Featured</label>
|
||||||
|
<div class="c--dim f7">Bump to the top of the project index and show in the carousel.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
{{ if .Editing }}
|
||||||
|
<input type="submit" value="Save" />
|
||||||
|
{{ else }}
|
||||||
|
<a class="button" href="javascript:;" onclick="gotoTab('description');">Next</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab" data-name="Description" data-slug="description">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Short description:</div>
|
||||||
|
<div>
|
||||||
|
<textarea maxlength="140" name="shortdesc">
|
||||||
|
{{- .ProjectSettings.Blurb -}}
|
||||||
|
</textarea>
|
||||||
|
<div class="c--dim f7">Plaintext only. No links or markdown.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Full description:</div>
|
||||||
|
<div>
|
||||||
|
<textarea class="w-100 h6 minh-6 mono lh-copy" name="description">
|
||||||
|
{{- .ProjectSettings.Description -}}
|
||||||
|
</textarea>
|
||||||
|
{{/* TODO(asaf): Replace with the full editor */}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
{{ if .Editing }}
|
||||||
|
<input type="submit" value="Save" />
|
||||||
|
{{ else }}
|
||||||
|
<a class="button" href="javascript:;" onclick="gotoTab('assets');">Next</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab" data-name="Assets" data-slug="assets">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Light theme logo:</div>
|
||||||
|
<div class="light_logo">
|
||||||
|
{{ template "image_selector.html" imageselectordata "light_logo" .ProjectSettings.LightLogo false }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Dark theme logo:</div>
|
||||||
|
<div class="dark_logo">
|
||||||
|
{{ template "image_selector.html" imageselectordata "dark_logo" .ProjectSettings.DarkLogo false }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
{{ if .Editing }}
|
||||||
|
<input type="submit" value="Save" />
|
||||||
|
{{ else }}
|
||||||
|
<input type="submit" value="Create project" />
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let csrf = JSON.parse({{ csrftokenjs .Session }});
|
||||||
|
|
||||||
|
let projectForm = document.querySelector("#project_form")
|
||||||
|
|
||||||
|
projectForm.addEventListener("invalid", function(ev) {
|
||||||
|
switchToTabOfElement(document.body, ev.target);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function gotoTab(tabName) {
|
||||||
|
switchTab(document.body, tabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////
|
||||||
|
// Owner management //
|
||||||
|
//////////////////////
|
||||||
|
|
||||||
|
const OWNER_QUERY_STATE_IDLE = 0;
|
||||||
|
const OWNER_QUERY_STATE_QUERYING = 1;
|
||||||
|
|
||||||
|
let ownerCheckUrl = "{{ .APICheckUsernameUrl }}";
|
||||||
|
let ownerQueryState = OWNER_QUERY_STATE_IDLE;
|
||||||
|
let addOwnerInput = document.querySelector("#owner_name");
|
||||||
|
let addOwnerButton = document.querySelector("#owner_add");
|
||||||
|
let ownersError = document.querySelector("#owners_error");
|
||||||
|
let ownerList = document.querySelector("#owner_list");
|
||||||
|
let ownerTemplate = makeTemplateCloner("owner_row");
|
||||||
|
|
||||||
|
addOwnerInput.addEventListener("keypress", function(ev) {
|
||||||
|
if (ev.which == 13) {
|
||||||
|
startAddOwner();
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addOwnerButton.addEventListener("click", function(ev) {
|
||||||
|
startAddOwner();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startAddOwner() {
|
||||||
|
if (ownerQueryState == OWNER_QUERY_STATE_QUERYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newOwner = addOwnerInput.value.trim().toLowerCase();
|
||||||
|
if (newOwner.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ownerEls = ownerList.querySelectorAll(".owner_row input[name='owners']");
|
||||||
|
for (let i = 0; i < ownerEls.length; ++i) {
|
||||||
|
let existingOwner = ownerEls[i].value.toLowerCase();
|
||||||
|
if (newOwner == existingOwner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ownersError.textContent = "";
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", ownerCheckUrl);
|
||||||
|
xhr.responseType = "json";
|
||||||
|
xhr.addEventListener("load", function(ev) {
|
||||||
|
let result = xhr.response;
|
||||||
|
if (result.found) {
|
||||||
|
addOwner(result.canonical);
|
||||||
|
addOwnerInput.value = "";
|
||||||
|
} else {
|
||||||
|
ownersError.textContent = "Username not found";
|
||||||
|
}
|
||||||
|
setOwnerQueryState(OWNER_QUERY_STATE_IDLE);
|
||||||
|
if (document.activeElement == addOwnerButton) {
|
||||||
|
addOwnerInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener("error", function(ev) {
|
||||||
|
ownersError.textContent = "There was an issue validating this username";
|
||||||
|
setOwnerQueryState(OWNER_QUERY_STATE_IDLE);
|
||||||
|
});
|
||||||
|
let data = new FormData();
|
||||||
|
data.append(csrf.field, csrf.token);
|
||||||
|
data.append("username", newOwner);
|
||||||
|
xhr.send(data);
|
||||||
|
setOwnerQueryState(OWNER_QUERY_STATE_QUERYING);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOwnerQueryState(state) {
|
||||||
|
ownerQueryState = state;
|
||||||
|
querying = (ownerQueryState == OWNER_QUERY_STATE_QUERYING);
|
||||||
|
addOwnerInput.disabled = querying;
|
||||||
|
addOwnerButton.disabled = querying;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOwner(username) {
|
||||||
|
let ownerEl = ownerTemplate();
|
||||||
|
ownerEl.input.value = username;
|
||||||
|
ownerEl.name.textContent = username;
|
||||||
|
ownerList.appendChild(ownerEl.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerList.addEventListener("click", function(ev) {
|
||||||
|
if (ev.target.classList.contains("remove_owner")) {
|
||||||
|
ev.target.parentElement.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/////////////////////
|
||||||
|
// Logo management //
|
||||||
|
/////////////////////
|
||||||
|
|
||||||
|
const logoMaxFileSize = {{ .LogoMaxFileSize }};
|
||||||
|
|
||||||
|
let lightLogoSelector = new ImageSelector(
|
||||||
|
document.querySelector("#project_form"),
|
||||||
|
logoMaxFileSize,
|
||||||
|
document.querySelector(".light_logo")
|
||||||
|
);
|
||||||
|
|
||||||
|
let darkLogoSelector = new ImageSelector(
|
||||||
|
document.querySelector("#project_form"),
|
||||||
|
logoMaxFileSize,
|
||||||
|
document.querySelector(".dark_logo")
|
||||||
|
);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
|
@ -36,31 +36,33 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
{{ with .ProfileUserProjects }}
|
{{ if or .OwnProfile .ProfileUserProjects }}
|
||||||
<div class="content-block ph3 ph0-ns">
|
<div class="content-block ph3 ph0-ns">
|
||||||
<h2>Projects</h2>
|
<h2>{{ if .OwnProfile }}My {{ end }}Projects</h2>
|
||||||
{{ range . }}
|
{{ range .ProfileUserProjects }}
|
||||||
<div class="mv3">
|
<div class="mv3">
|
||||||
{{ template "project_card.html" projectcarddata . "" }}
|
{{ template "project_card.html" projectcarddata . "" }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .OwnProfile }}
|
||||||
|
<a href="{{ .NewProjectUrl }}">+ New Project</a>
|
||||||
|
{{ 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>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if gt (len .TimelineItems) 0 }}
|
{{ if gt (len .TimelineItems) 0 }}
|
||||||
<div class="content-block timeline-container ph3 ph0-ns">
|
<div class="mv3 content-block timeline-container ph3 ph0-ns">
|
||||||
<h2>Recent Activity</h2>
|
<h2>Recent Activity</h2>
|
||||||
<div class="timeline-filters mb2">
|
<div class="timeline-filters mb2">
|
||||||
{{/*
|
|
||||||
{{ if gt .NumForums 0 }}
|
|
||||||
<div class="dib filter forums mr2"><input data-type="forums" class="v-mid mr1" type="checkbox" id="timeline-checkbox-forums" checked /><label class="v-mid" for="timeline-checkbox-forums">Forums (<span class="count">{{ .NumForums }}</span>)</label></div>
|
|
||||||
{{ end }}
|
|
||||||
{{ if gt .NumBlogs 0 }}
|
|
||||||
<div class="dib filter blogs mr2"><input data-type="blogs" class="v-mid mr1" type="checkbox" id="timeline-checkbox-blogs" checked /><label class="v-mid" for="timeline-checkbox-blogs">Blogs (<span class="count">{{ .NumBlogs }}</span>)</label></div>
|
|
||||||
{{ end }}
|
|
||||||
{{ if gt .NumSnippets 0 }}
|
|
||||||
<div class="dib filter snippets mr2"><input data-type="snippets" class="v-mid mr1" type="checkbox" id="timeline-checkbox-snippets" checked /><label class="v-mid" for="timeline-checkbox-snippets">Snippets (<span class="count">{{ .NumSnippets }}</span>)</label></div>
|
|
||||||
{{ end }}
|
|
||||||
*/}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
{{ range .TimelineItems }}
|
{{ range .TimelineItems }}
|
||||||
|
|
|
@ -110,6 +110,9 @@ var HMNTemplateFuncs = template.FuncMap{
|
||||||
"csrftoken": func(s Session) template.HTML {
|
"csrftoken": func(s Session) template.HTML {
|
||||||
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, auth.CSRFFieldName, s.CSRFToken))
|
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, 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 {
|
"darken": func(amount float64, color noire.Color) noire.Color {
|
||||||
return color.Shade(amount)
|
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 },
|
"mediaimage": func() TimelineItemMediaType { return TimelineItemMediaTypeImage },
|
||||||
"mediavideo": func() TimelineItemMediaType { return TimelineItemMediaTypeVideo },
|
"mediavideo": func() TimelineItemMediaType { return TimelineItemMediaTypeVideo },
|
||||||
"mediaaudio": func() TimelineItemMediaType { return TimelineItemMediaTypeAudio },
|
"mediaaudio": func() TimelineItemMediaType { return TimelineItemMediaTypeAudio },
|
||||||
|
|
|
@ -133,6 +133,22 @@ type Project struct {
|
||||||
DateApproved time.Time
|
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 {
|
type User struct {
|
||||||
ID int
|
ID int
|
||||||
Username string
|
Username string
|
||||||
|
@ -304,6 +320,12 @@ type ProjectCardData struct {
|
||||||
Classes string
|
Classes string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImageSelectorData struct {
|
||||||
|
Name string
|
||||||
|
Src string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
type Breadcrumb struct {
|
type Breadcrumb struct {
|
||||||
Name, Url string
|
Name, Url string
|
||||||
Current bool
|
Current bool
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -362,3 +362,53 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
return res
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -196,6 +196,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
||||||
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
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.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
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.RegexPodcastEpisode, PodcastEpisode)
|
||||||
hmnOnly.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
hmnOnly.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||||
|
|
||||||
|
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
||||||
|
|
||||||
attachProjectRoutes := func(rb *RouteBuilder) {
|
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
|
// Middleware used for forum action routes - anything related to actually creating or editing forum content
|
||||||
needsForums := func(h Handler) Handler {
|
needsForums := func(h Handler) Handler {
|
||||||
return func(c *RequestContext) ResponseData {
|
return func(c *RequestContext) ResponseData {
|
||||||
|
|
|
@ -26,6 +26,9 @@ type UserProfileTemplateData struct {
|
||||||
ProfileUserLinks []templates.Link
|
ProfileUserLinks []templates.Link
|
||||||
ProfileUserProjects []templates.Project
|
ProfileUserProjects []templates.Project
|
||||||
TimelineItems []templates.TimelineItem
|
TimelineItems []templates.TimelineItem
|
||||||
|
OwnProfile bool
|
||||||
|
ShowcaseUrl string
|
||||||
|
NewProjectUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserProfile(c *RequestContext) ResponseData {
|
func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
@ -194,6 +197,9 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
ProfileUserLinks: profileUserLinks,
|
ProfileUserLinks: profileUserLinks,
|
||||||
ProfileUserProjects: templateProjects,
|
ProfileUserProjects: templateProjects,
|
||||||
TimelineItems: timelineItems,
|
TimelineItems: timelineItems,
|
||||||
|
OwnProfile: c.CurrentUser.ID == profileUser.ID,
|
||||||
|
ShowcaseUrl: hmnurl.BuildShowcase(),
|
||||||
|
NewProjectUrl: hmnurl.BuildProjectNew(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue