582 lines
19 KiB
HTML
582 lines
19 KiB
HTML
{{ template "base-2024.html" . }}
|
|
|
|
{{ define "extrahead" }}
|
|
{{ template "markdown_previews.html" .TextEditor }}
|
|
<script src="{{ static "js/image_selector.js" }}"></script>
|
|
<script src="{{ static "js/templates.js" }}"></script>
|
|
<script src="{{ static "js/base64.js" }}"></script>
|
|
<script src="{{ static "js/markdown_upload.js" }}"></script>
|
|
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
|
|
|
<script>
|
|
const go = new Go();
|
|
WebAssembly.instantiateStreaming(fetch('{{ static "parsing.wasm" }}'), go.importObject)
|
|
.then(result => {
|
|
go.run(result.instance); // don't await this; we want it to be continuously running
|
|
updateLinkPreviews();
|
|
});
|
|
window.parseKnownServicesForUrl = null; // will be set by the Go code
|
|
</script>
|
|
|
|
<style>
|
|
#desc_preview:empty::after {
|
|
content: 'A preview of your description will appear here.';
|
|
color: var(--dimmer-color);
|
|
font-style: italic;
|
|
}
|
|
|
|
#full_description.drop {
|
|
box-shadow: inset 0px 0px 5px yellow;
|
|
}
|
|
</style>
|
|
{{ end }}
|
|
|
|
{{ define "content" }}
|
|
<div class="flex bb">
|
|
<form id="project_form" class="hmn-form bg1 pa4 flex-fair" method="POST" enctype="multipart/form-data">
|
|
{{ csrftoken .Session }}
|
|
|
|
<h1 class="f3">
|
|
{{ if .Editing }}
|
|
Edit {{ .ProjectSettings.Name }}
|
|
{{ else }}
|
|
Create a {{ if .ProjectSettings.JamParticipation }}jam {{ end }}project
|
|
{{ end }}
|
|
</h1>
|
|
|
|
<hr class="mv3">
|
|
|
|
<div class="flex flex-column g3">
|
|
<div class="input-group">
|
|
<label>Project Title*</label>
|
|
<input
|
|
required
|
|
type="text"
|
|
id="project_name" name="project_name"
|
|
maxlength="255"
|
|
value="{{ .ProjectSettings.Name }}"
|
|
oninput="updateCardPreview()"
|
|
>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>Summary*</label>
|
|
<textarea
|
|
required
|
|
id="description" name="shortdesc"
|
|
maxlength="140"
|
|
oninput="updateCardPreview()"
|
|
>
|
|
{{- .ProjectSettings.Blurb -}}
|
|
</textarea>
|
|
<div class="f6">Plain text only. No links or markdown.</div>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>Long Description</label>
|
|
<textarea id="full_description" class="w-100 h5 minh-5 mono lh-copy" name="full_description">
|
|
{{- .ProjectSettings.Description -}}
|
|
</textarea>
|
|
<div class="flex justify-end items-center f6">
|
|
<div class="upload_bar flex-grow-1">
|
|
<div class="instructions">
|
|
Upload files by dragging & dropping, pasting, or <label class="pointer link" for="file_input">selecting</label> them.
|
|
</div>
|
|
<div class="progress flex">
|
|
<div class="progress_text mr3"></div>
|
|
<div class="progress_bar flex-grow-1 flex-shrink-1 pa1"><div class=""></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>Status</label>
|
|
<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 }}>On 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>
|
|
|
|
<fieldset>
|
|
<legend>Discord Tag</legend>
|
|
<div class="pa3 input-group">
|
|
<input
|
|
id="tag" name="tag" type="text"
|
|
pattern="^[a-z0-9]+(-[a-z0-9]+)*$" maxlength="20"
|
|
value="{{ .ProjectSettings.Tag }}"
|
|
>
|
|
<div class="f6 mt1" id="tag-discord-info">If you have linked your Discord account, any #project-showcase messages with the tag "&<span id="tag-preview"></span>" will automatically be associated with this project.</div>
|
|
<div class="f6">Tags must be all lowercase, and can use hyphens to separate words.</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend class="flex justify-between">
|
|
<span>Project Logo</span>
|
|
<a href="#" class="normal" onclick="openLogoSelector(event)">+ Upload Project Logo</a>
|
|
</legend>
|
|
<div class="light_logo">
|
|
{{ template "image_selector.html" imageselectordata "light_logo" .ProjectSettings.LightLogo false }}
|
|
<div class="show-when-sibling-hidden flex justify-center items-center f6 pa2">Images should be square, and at least 256x256.</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend class="flex justify-between">
|
|
<span>Header Image</span>
|
|
<a href="#" class="normal" onclick="openHeaderSelector(event)">+ Upload Header Image</a>
|
|
</legend>
|
|
<div class="header_image">
|
|
{{ template "image_selector.html" imageselectordata "header_image" .ProjectSettings.HeaderImage false }}
|
|
<div class="show-when-sibling-hidden flex justify-center items-center f6 pa2">Images should be very beeg (TODO)</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Owners</legend>
|
|
<div class="pa3">
|
|
<div class="flex">
|
|
<input class="flex-grow-1 bl bt bb br-0" id="owner_name" type="text" placeholder="Enter another owner's username" />
|
|
<button class="flex no-padding pa3 bt br bb bl-0 also-focus" id="owner_add"><span class="flex w1">{{ svg "add" }}</span></button>
|
|
</div>
|
|
<div id="owners_error" class="f6"></div>
|
|
<div id="owner_list" class="pt3 flex flex-wrap g3">
|
|
<template id="owner_row">
|
|
<div class="owner_row flex flex-row items-center g2 bg3 pa2" data-tmpl="root">
|
|
<input type="hidden" name="owners" data-tmpl="input" />
|
|
<div class="flex g1 items-center b">
|
|
<img data-tmpl="avatar" class="avatar avatar-user avatar-small" src="" />
|
|
<span data-tmpl="name"></span>
|
|
</div>
|
|
<a class="remove_owner svgicon f7 link-normal" href="javascript:;">{{ svg "close" }}</a>
|
|
</div>
|
|
</template>
|
|
{{ range .ProjectSettings.Owners }}
|
|
<div class="owner_row flex flex-row items-center g2 bg3 pa2">
|
|
<input type="hidden" name="owners" value="{{ .Username }}" />
|
|
<div class="flex g1 items-center b">
|
|
<img class="avatar avatar-user avatar-small" src="{{ .AvatarUrl }}" />
|
|
<span title="{{ .Username }}">{{ .Name }}</span>
|
|
</div>
|
|
{{ if (or $.User.IsStaff (ne .ID $.User.ID)) }}
|
|
<a class="remove_owner svgicon f7 link-normal" href="javascript:;">{{ svg "close" }}</a>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{{ if .ProjectSettings.JamParticipation }}
|
|
<fieldset>
|
|
<legend>Jam Participation</legend>
|
|
<div class="pa3 flex flex-column g2">
|
|
{{ range .ProjectSettings.JamParticipation }}
|
|
<div>
|
|
<input id="jam_{{ .JamSlug }}" type="checkbox" name="jam_participation" value="{{ .JamSlug }}" {{ if .Participating }}checked{{ end }} />
|
|
<label for="jam_{{ .JamSlug }}">{{ .JamName }}</label>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
</fieldset>
|
|
{{ end }}
|
|
|
|
{{/*
|
|
TODO(redesign): Two problems with the link editor:
|
|
1. It doesn't start out with a valid "empty" state, i.e. one set of empty fields
|
|
2. When it is in its empty state, it still renders one link in the live preview.
|
|
*/}}
|
|
{{ template "link_editor.html" .ProjectSettings.LinksJSON }}
|
|
|
|
{{ if and .Editing .User.IsStaff }}
|
|
<fieldset>
|
|
<legend>Admin Properties</legend>
|
|
<div class="pa3 flex flex-column g3">
|
|
<div class="flex flex-column g2">
|
|
<div>
|
|
<input id="official" type="checkbox" name="official" {{ if not .ProjectSettings.Personal }}checked{{ end }}>
|
|
<label for="official">Official HMN project</label>
|
|
</div>
|
|
<div>
|
|
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
|
|
<label for="hidden">Hide project</label>
|
|
</div>
|
|
<div>
|
|
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
|
|
<label for="featured">Featured</label>
|
|
</div>
|
|
</div>
|
|
<div class="input-group">
|
|
<label for="slug">Slug</label>
|
|
<input type="text" id="slug" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
|
|
<div class="f6">Has no effect for personal projects. Personal projects have a slug derived from the title.</div>
|
|
<div class="f6">If you change this, make sure to change DNS too!</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
{{ end }}
|
|
|
|
<div class="flex justify-end">
|
|
{{ if .Editing }}
|
|
<input class="btn-primary" type="submit" value="Save" />
|
|
{{ else }}
|
|
<input class="btn-primary" type="submit" value="Create Project" />
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<div class="flex-fair bl pa4">
|
|
<!-- Link / card templates -->
|
|
<template id="primary_link">
|
|
<!-- need href -->
|
|
<a data-tmpl="root" class="ph3 pv2 flex items-center"><span data-tmpl="name"></span><span class="svgicon f6 ml2">{{ svg "arrow-right-up" }}</span></a>
|
|
</template>
|
|
<template id="secondary_link">
|
|
<!-- need href and title -->
|
|
<a data-tmpl="root" class="ph2 flex"><!-- need icon --></a>
|
|
</template>
|
|
<template id="owner_preview">
|
|
<div class="flex g1 items-center b">
|
|
<img data-tmpl="avatar" class="avatar avatar-user avatar-small"><!-- need src -->
|
|
<span data-tmpl="name"><!-- need name --></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div hidden>
|
|
{{ range .AllLogos }}
|
|
<span id="link-icon-{{ .Name }}">{{ .Svg }}</span>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<!--
|
|
NOTE(ben): This is a copy-paste from project_homepage.html right now.
|
|
We don't have a good story for sharing templates between Go and JS.
|
|
-->
|
|
|
|
<div class="flex flex-column items-center">
|
|
<!-- Header image / links -->
|
|
<div id="header_img_preview" class="project-header-img"><!-- Needs background-image -->
|
|
<div class="flex justify-end pa3">
|
|
<div class="flex g3">
|
|
<div id="primary_links_preview" class="project-links hide-if-empty"></div>
|
|
<div id="secondary_links_preview" class="project-links ph1 hide-if-empty"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card -->
|
|
<div class="project-homepage-card">
|
|
<div id="logo_preview" class="flex-shrink-0 flex">
|
|
<img class="project-card-logo" alt="Project Logo">
|
|
</div>
|
|
<div class="details flex-grow-1">
|
|
<h3 id="name_preview" class="b mb2 f4"></h3>
|
|
<div id="blurb_preview" class="blurb"></div>
|
|
<div id="owners_preview_container">
|
|
<hr class="mv3">
|
|
<div id="owners_preview" class="flex flex-wrap g2">
|
|
</div>
|
|
</div>
|
|
<!-- TODO(redesign): Preview badges -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Long description preview -->
|
|
<h3 class="pt4 f4">About {{ .Project.Name }}</h3>
|
|
<hr class="mv3">
|
|
<div id="desc_preview" class="w-100 post-content"></div>
|
|
</div>
|
|
</div>
|
|
<input type="file" multiple name="file_input" id="file_input" class="dn" />{{/* NOTE(mark): copied NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}}
|
|
|
|
<script>
|
|
let csrf = JSON.parse({{ csrftokenjs .Session }});
|
|
|
|
let projectForm = document.querySelector("#project_form");
|
|
|
|
//////////
|
|
// Tags //
|
|
//////////
|
|
|
|
const tag = document.querySelector('#tag');
|
|
const tagPreview = document.querySelector('#tag-preview');
|
|
function updateTagPreview() {
|
|
tagPreview.innerText = tag.value || "[your tag]";
|
|
}
|
|
updateTagPreview();
|
|
tag.addEventListener('input', () => updateTagPreview());
|
|
|
|
////////////////////////////
|
|
// Description management //
|
|
////////////////////////////
|
|
|
|
{{ if .Editing }}
|
|
const projectName = "new-project";
|
|
{{ else }}
|
|
const projectName = "{{ .Project.Name }}";
|
|
{{ end }}
|
|
const description = document.querySelector('#full_description');
|
|
const descPreview = document.querySelector('#desc_preview');
|
|
const { clear: clearDescription } = autosaveContent({
|
|
inputEl: description,
|
|
storageKey: `project-description/${projectName}`,
|
|
});
|
|
projectForm.addEventListener('submit', () => clearDescription());
|
|
|
|
let doMarkdown = initLiveMarkdown({ inputEl: description, previewEl: descPreview });
|
|
|
|
//////////////////////
|
|
// Owner management //
|
|
//////////////////////
|
|
|
|
const OWNER_QUERY_STATE_IDLE = 0;
|
|
const OWNER_QUERY_STATE_QUERYING = 1;
|
|
|
|
const MAX_OWNERS = {{ .MaxOwners }};
|
|
|
|
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");
|
|
let ownerPreviewTemplate = makeTemplateCloner("owner_preview");
|
|
let ownersPreviewContainer = document.querySelector("#owners_preview");
|
|
|
|
addOwnerInput.addEventListener("keypress", function(ev) {
|
|
if (ev.which == 13) {
|
|
startAddOwner();
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
});
|
|
|
|
addOwnerButton.addEventListener("click", function(ev) {
|
|
ev.preventDefault();
|
|
startAddOwner();
|
|
});
|
|
|
|
function updateAddOwnerStyles() {
|
|
const numOwnerRows = document.querySelectorAll('.owner_row').length;
|
|
addOwnerInput.disabled = numOwnerRows >= MAX_OWNERS;
|
|
}
|
|
updateAddOwnerStyles();
|
|
|
|
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.withCredentials = true;
|
|
xhr.open("POST", ownerCheckUrl);
|
|
xhr.responseType = "json";
|
|
xhr.addEventListener("load", function(ev) {
|
|
let result = xhr.response;
|
|
if (result) {
|
|
if (result.found) {
|
|
addOwner(result.username, result.name, result.avatarUrl);
|
|
addOwnerInput.value = "";
|
|
} else {
|
|
ownersError.textContent = "Username not found";
|
|
}
|
|
} else {
|
|
ownersError.textContent = "There was an issue validating this username";
|
|
}
|
|
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;
|
|
updateAddOwnerStyles();
|
|
}
|
|
|
|
function addOwner(username, bestName, avatarUrl) {
|
|
let ownerEl = ownerTemplate();
|
|
ownerEl.input.value = username;
|
|
ownerEl.name.textContent = bestName;
|
|
ownerEl.title = username;
|
|
ownerEl.avatar.src = avatarUrl;
|
|
ownerList.appendChild(ownerEl.root);
|
|
updateAddOwnerStyles();
|
|
updateOwnersPreview();
|
|
}
|
|
|
|
ownerList.addEventListener("click", function(ev) {
|
|
if (ev.target.closest(".remove_owner")) {
|
|
ev.target.closest(".owner_row").remove();
|
|
}
|
|
updateAddOwnerStyles();
|
|
updateOwnersPreview();
|
|
});
|
|
|
|
function updateOwnersPreview() {
|
|
let ownerEls = ownerList.querySelectorAll(".owner_row");
|
|
ownersPreviewContainer.innerHTML = "";
|
|
for (let i = 0; i < ownerEls.length; ++i) {
|
|
let avatarUrl = ownerEls[i].querySelector("img").src;
|
|
let name = ownerEls[i].querySelector("span").textContent;
|
|
let previewEl = ownerPreviewTemplate();
|
|
previewEl.avatar.src = avatarUrl;
|
|
previewEl.name.textContent = name;
|
|
ownersPreviewContainer.appendChild(previewEl.root);
|
|
}
|
|
|
|
}
|
|
|
|
updateOwnersPreview();
|
|
|
|
//////////////////////////////
|
|
// Logo / header management //
|
|
//////////////////////////////
|
|
|
|
const logoMaxFileSize = {{ .LogoMaxFileSize }};
|
|
const headerMaxFileSize = {{ .HeaderMaxFileSize }};
|
|
|
|
const logoSelector = new ImageSelector(
|
|
document.querySelector("#project_form"),
|
|
logoMaxFileSize,
|
|
document.querySelector(".light_logo"),
|
|
{
|
|
onUpdate() {
|
|
updateCardPreview();
|
|
},
|
|
},
|
|
);
|
|
function openLogoSelector(e) {
|
|
e.preventDefault();
|
|
logoSelector.openFileInput();
|
|
}
|
|
|
|
const headerSelector = new ImageSelector(
|
|
document.querySelector("#project_form"),
|
|
headerMaxFileSize,
|
|
document.querySelector(".header_image"),
|
|
{
|
|
onUpdate() {
|
|
updateCardPreview();
|
|
},
|
|
},
|
|
);
|
|
function openHeaderSelector(e) {
|
|
e.preventDefault();
|
|
headerSelector.openFileInput();
|
|
}
|
|
|
|
function updateCardPreview() {
|
|
document.querySelector("#logo_preview").hidden = !logoSelector.url;
|
|
document.querySelector("#logo_preview img").src = logoSelector.url;
|
|
document.querySelector("#header_img_preview").style.backgroundImage = `url(${headerSelector.url})`;
|
|
document.querySelector("#name_preview").innerText = document.querySelector("#project_name").value;
|
|
document.querySelector("#blurb_preview").innerText = document.querySelector("#description").value;
|
|
}
|
|
updateCardPreview();
|
|
|
|
//////////////////
|
|
// Asset upload //
|
|
//////////////////
|
|
setupMarkdownUpload(
|
|
document.querySelectorAll("#project_form input[type=submit]"),
|
|
document.querySelector('#file_input'),
|
|
document.querySelector('.upload_bar'),
|
|
description,
|
|
doMarkdown,
|
|
{{ .TextEditor.MaxFileSize }},
|
|
{{ .TextEditor.UploadUrl }}
|
|
);
|
|
|
|
/////////////////////
|
|
// Link management //
|
|
/////////////////////
|
|
|
|
const primaryLinkTemplate = makeTemplateCloner("primary_link");
|
|
const secondaryLinkTemplate = makeTemplateCloner("secondary_link");
|
|
|
|
function updateLinkPreviews() {
|
|
const links = document.querySelector("#links");
|
|
const linksChildren = Array.from(links.children);
|
|
|
|
const secondaryHeader = links.querySelector(".secondary_links");
|
|
const rows = links.querySelectorAll(".link_row");
|
|
|
|
function index(el) {
|
|
return linksChildren.indexOf(el);
|
|
}
|
|
|
|
const primaryPreview = document.querySelector("#primary_links_preview");
|
|
const secondaryPreview = document.querySelector("#secondary_links_preview");
|
|
|
|
primaryPreview.innerHTML = "";
|
|
secondaryPreview.innerHTML = "";
|
|
|
|
const indexOfSecondary = index(secondaryHeader);
|
|
for (const row of rows) {
|
|
const name = row.querySelector(".link_name").value;
|
|
const url = row.querySelector(".link_url").value;
|
|
|
|
const primary = index(row) < indexOfSecondary;
|
|
if (primary) {
|
|
const l = primaryLinkTemplate();
|
|
l.root.href = url;
|
|
l.name.innerText = name;
|
|
primaryPreview.appendChild(l.root);
|
|
} else {
|
|
let icon = "website";
|
|
let title = "";
|
|
if (parseKnownServicesForUrl) {
|
|
const guess = parseKnownServicesForUrl(url);
|
|
icon = guess.icon;
|
|
title = guess.service;
|
|
if (guess.username) {
|
|
title += ` (${guess.username})`;
|
|
}
|
|
}
|
|
const iconSVG = document.querySelector(`#link-icon-${icon}`).innerHTML;
|
|
|
|
const l = secondaryLinkTemplate();
|
|
l.root.href = url;
|
|
l.root.title = name || title;
|
|
l.root.innerHTML = iconSVG;
|
|
secondaryPreview.appendChild(l.root);
|
|
}
|
|
}
|
|
}
|
|
updateLinkPreviews();
|
|
window.addEventListener("linkedit", () => updateLinkPreviews());
|
|
|
|
</script>
|
|
{{ end }}
|