Add interactive link previews

This commit is contained in:
Ben Visness 2024-07-02 21:23:23 -05:00
parent bab955aaff
commit 51ad8d03d4
12 changed files with 316 additions and 107 deletions

Binary file not shown.

View File

@ -8484,6 +8484,23 @@ span.icon-rss::before {
height: var(--image-size); height: var(--image-size);
object-fit: cover; object-fit: cover;
} }
.project-header-img {
width: 100%;
height: var(--height-5);
background-color: rgba(255, 255, 255, 0.5);
background-size: cover;
}
.project-links {
background-color: var(--c-transparent-background);
display: flex;
align-items: center;
--link-color: var(--color);
font-weight: bold;
}
.project-links::after {
content: "\200b";
padding: var(--spacing-2) 0;
}
/* src/rawdata/scss/showcase.css */ /* src/rawdata/scss/showcase.css */
.showcase-item .gradient { .showcase-item .gradient {

94
src/links/links.go Normal file
View File

@ -0,0 +1,94 @@
package links
import "regexp"
//
// This is all in its own package so we can compile it to wasm without building extra junk.
//
// An online site/service for which we recognize the link
type Service struct {
Name string
IconName string
Regex *regexp.Regexp
}
var Services = []Service{
// {
// Name: "itch.io",
// IconName: "itch",
// Regex: regexp.MustCompile(`://(?P<username>[\w-]+)\.itch\.io`),
// },
{
Name: "App Store",
IconName: "app-store",
Regex: regexp.MustCompile(`^https?://apps.apple.com`),
},
{
Name: "Bluesky",
IconName: "bluesky",
Regex: regexp.MustCompile(`^https?://bsky.app/profile/(?P<username>[\w.-]+)$`),
},
{
Name: "Discord",
IconName: "discord",
Regex: regexp.MustCompile(`^https?://discord\.gg`),
},
{
Name: "GitHub",
IconName: "github",
Regex: regexp.MustCompile(`^https?://github\.com/(?P<username>[\w/-]+)`),
},
{
Name: "GitLab",
IconName: "gitlab",
Regex: regexp.MustCompile(`^https?://gitlab\.com/(?P<username>[\w/-]+)`),
},
{
Name: "Google Play",
IconName: "google-play",
Regex: regexp.MustCompile(`^https?://play\.google\.com`),
},
{
Name: "Patreon",
IconName: "patreon",
Regex: regexp.MustCompile(`^https?://patreon\.com/(?P<username>[\w-]+)`),
},
{
Name: "Twitch",
IconName: "twitch",
Regex: regexp.MustCompile(`^https?://twitch\.tv/(?P<username>[\w/-]+)`),
},
{
Name: "Twitter",
IconName: "twitter",
Regex: regexp.MustCompile(`^https?://(twitter|x)\.com/(?P<username>\w+)`),
},
{
Name: "Vimeo",
IconName: "vimeo",
Regex: regexp.MustCompile(`^https?://vimeo\.com/(?P<username>\w+)`),
},
{
Name: "YouTube",
IconName: "youtube",
Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P<username>[@\w/-]+)$`),
},
}
func ParseKnownServicesForUrl(url string) (service Service, username string) {
for _, svc := range Services {
match := svc.Regex.FindStringSubmatch(url)
if match != nil {
username := ""
if idx := svc.Regex.SubexpIndex("username"); idx >= 0 {
username = match[idx]
}
return svc, username
}
}
return Service{
IconName: "website",
}, ""
}

View File

@ -5,16 +5,25 @@ package main
import ( import (
"syscall/js" "syscall/js"
"git.handmade.network/hmn/hmn/src/links"
"git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/parsing"
) )
func main() { func main() {
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) any {
return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown) return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown)
})) }))
js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) interface{} { js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) any {
return parsing.ParseMarkdown(args[0].String(), parsing.EducationPreviewMarkdown) return parsing.ParseMarkdown(args[0].String(), parsing.EducationPreviewMarkdown)
})) }))
js.Global().Set("parseKnownServicesForUrl", js.FuncOf(func(this js.Value, args []js.Value) any {
service, username := links.ParseKnownServicesForUrl(args[0].String())
return js.ValueOf(map[string]any{
"service": service.Name,
"icon": service.IconName,
"username": username,
})
}))
var done chan struct{} var done chan struct{}
<-done // block forever <-done // block forever

View File

@ -72,3 +72,26 @@
height: var(--image-size); height: var(--image-size);
object-fit: cover; object-fit: cover;
} }
.project-header-img {
/* w-100 h5 bg-white-50 bg-center cover */
width: 100%;
height: var(--height-5);
/* TODO(redesign): Better placeholder */
background-color: rgba(255, 255, 255, 0.5);
background-size: cover;
}
.project-links {
background-color: var(--c-transparent-background);
display: flex;
align-items: center;
--link-color: var(--color);
font-weight: bold;
/* make sure secondary links render at the right height despite SVG size */
&::after {
content: '\200b';
padding: var(--spacing-2) 0;
}
}

View File

@ -4,13 +4,13 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/netip" "net/netip"
"regexp"
"strconv" "strconv"
"strings" "strings"
"git.handmade.network/hmn/hmn/src/calendar" "git.handmade.network/hmn/hmn/src/calendar"
"git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/links"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
) )
@ -240,95 +240,8 @@ var UnknownUser = User{
AvatarUrl: UserAvatarUrl(nil), AvatarUrl: UserAvatarUrl(nil),
} }
// An online site/service for which we recognize the link
type LinkService struct {
Name string
IconName string
Regex *regexp.Regexp
}
var LinkServices = []LinkService{
// {
// Name: "itch.io",
// IconName: "itch",
// Regex: regexp.MustCompile(`://(?P<username>[\w-]+)\.itch\.io`),
// },
{
Name: "App Store",
IconName: "app-store",
Regex: regexp.MustCompile(`^https?://apps.apple.com`),
},
{
Name: "Bluesky",
IconName: "bluesky",
Regex: regexp.MustCompile(`^https?://bsky.app/profile/(?P<username>[\w.-]+)$`),
},
{
Name: "Discord",
IconName: "discord",
Regex: regexp.MustCompile(`^https?://discord\.gg`),
},
{
Name: "GitHub",
IconName: "github",
Regex: regexp.MustCompile(`^https?://github\.com/(?P<username>[\w/-]+)`),
},
{
Name: "GitLab",
IconName: "gitlab",
Regex: regexp.MustCompile(`^https?://gitlab\.com/(?P<username>[\w/-]+)`),
},
{
Name: "Google Play",
IconName: "google-play",
Regex: regexp.MustCompile(`^https?://play\.google\.com`),
},
{
Name: "Patreon",
IconName: "patreon",
Regex: regexp.MustCompile(`^https?://patreon\.com/(?P<username>[\w-]+)`),
},
{
Name: "Twitch",
IconName: "twitch",
Regex: regexp.MustCompile(`^https?://twitch\.tv/(?P<username>[\w/-]+)`),
},
{
Name: "Twitter",
IconName: "twitter",
Regex: regexp.MustCompile(`^https?://(twitter|x)\.com/(?P<username>\w+)`),
},
{
Name: "Vimeo",
IconName: "vimeo",
Regex: regexp.MustCompile(`^https?://vimeo\.com/(?P<username>\w+)`),
},
{
Name: "YouTube",
IconName: "youtube",
Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P<username>[@\w/-]+)$`),
},
}
func ParseKnownServicesForLink(link *models.Link) (service LinkService, username string) {
for _, svc := range LinkServices {
match := svc.Regex.FindStringSubmatch(link.URL)
if match != nil {
username := ""
if idx := svc.Regex.SubexpIndex("username"); idx >= 0 {
username = match[idx]
}
return svc, username
}
}
return LinkService{
IconName: "website",
}, ""
}
func LinkToTemplate(link *models.Link) Link { func LinkToTemplate(link *models.Link) Link {
service, username := ParseKnownServicesForLink(link) service, username := links.ParseKnownServicesForUrl(link.URL)
return Link{ return Link{
Name: link.Name, Name: link.Name,
Url: link.URL, Url: link.URL,

View File

@ -5,14 +5,14 @@
</legend> </legend>
<div class="pa3 input-group"> <div class="pa3 input-group">
<div id="links" class="flex flex-column g2 relative"> <div id="links" class="flex flex-column g2 relative">
<div class="b">Primary Links</div> <div class="b primary_links">Primary Links</div>
<div class="b drop_slot secondary_links">Secondary Links</div> <div class="b drop_slot secondary_links">Secondary Links</div>
</div> </div>
<template id="link_row"> <template id="link_row">
<div class="link_row drop_slot w-100 flex flex-row items-center" data-tmpl="root"> <div class="link_row drop_slot w-100 flex flex-row items-center" data-tmpl="root">
<span class="link_handle svgicon pr3 pointer grab" onmousedown="startLinkDrag(event)">{{ svg "draggable" }}</span> <span class="link_handle svgicon pr3 pointer grab" onmousedown="startLinkDrag(event)">{{ svg "draggable" }}</span>
<input data-tmpl="nameInput" class="link_name mr3 w5" type="text" placeholder="Name" /> <input data-tmpl="nameInput" class="link_name mr3 w5" type="text" placeholder="Name" oninput="linkInput(event)" />
<input data-tmpl="urlInput" class="link_url flex-grow-1" type="url" placeholder="Link" /> <input data-tmpl="urlInput" class="link_url flex-grow-1" type="url" placeholder="Link" oninput="linkInput(event)" />
<a class="delete_link svgicon link-normal pl3 f3" href="javascript:;" onclick="deleteLink(event)">{{ svg "delete" }}</a> <a class="delete_link svgicon link-normal pl3 f3" href="javascript:;" onclick="deleteLink(event)">{{ svg "delete" }}</a>
</div> </div>
</template> </template>
@ -51,6 +51,8 @@
function addLink(e) { function addLink(e) {
e.preventDefault(); e.preventDefault();
linksContainer.appendChild(linkTemplate().root); linksContainer.appendChild(linkTemplate().root);
fireLinkEditEvent();
} }
function deleteLink(e) { function deleteLink(e) {
@ -59,6 +61,8 @@
l.remove(); l.remove();
ensureLinksEmptyState(); ensureLinksEmptyState();
fireLinkEditEvent();
} }
function ensureLinksEmptyState() { function ensureLinksEmptyState() {
@ -169,9 +173,19 @@
document.body.classList.remove("grabbing"); document.body.classList.remove("grabbing");
draggingLink = null; draggingLink = null;
fireLinkEditEvent();
} }
window.addEventListener("mouseup", endLinkDrag); window.addEventListener("mouseup", endLinkDrag);
window.addEventListener("mousemove", doLinkDrag); window.addEventListener("mousemove", doLinkDrag);
function linkInput(e) {
fireLinkEditEvent();
}
function fireLinkEditEvent() {
window.dispatchEvent(new Event("linkedit"));
}
</script> </script>
</fieldset> </fieldset>

View File

@ -6,9 +6,20 @@
<script src="{{ static "js/templates.js" }}"></script> <script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/base64.js" }}"></script> <script src="{{ static "js/base64.js" }}"></script>
<script src="{{ static "js/markdown_upload.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> <style>
#desc-preview:empty::after { #desc_preview:empty::after {
content: 'A preview of your description will appear here.'; content: 'A preview of your description will appear here.';
color: var(--dimmer-color); color: var(--dimmer-color);
font-style: italic; font-style: italic;
@ -195,7 +206,38 @@
</div> </div>
</form> </form>
<div class="flex-fair bl pa4"> <div class="flex-fair bl pa4">
<div id="desc-preview" class="w-100 post-content"></div> <!-- 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>
<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.
-->
<!-- 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>
<!-- Long description preview -->
<div id="desc_preview" class="w-100 post-content"></div>
</div> </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 */}} <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 */}}
@ -235,7 +277,7 @@
const projectName = "{{ .Project.Name }}"; const projectName = "{{ .Project.Name }}";
{{ end }} {{ end }}
const description = document.querySelector('#full_description'); const description = document.querySelector('#full_description');
const descPreview = document.querySelector('#desc-preview'); const descPreview = document.querySelector('#desc_preview');
const { clear: clearDescription } = autosaveContent({ const { clear: clearDescription } = autosaveContent({
inputEl: description, inputEl: description,
storageKey: `project-description/${projectName}`, storageKey: `project-description/${projectName}`,
@ -397,5 +439,64 @@
{{ .TextEditor.UploadUrl }} {{ .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> </script>
{{ end }} {{ end }}

View File

@ -10,8 +10,8 @@
{{ define "content" }} {{ define "content" }}
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex-grow-1 flex flex-column items-center mw-site pt4"> <div class="flex-grow-1 flex flex-column items-center mw-site pt4">
<div class="w-100 h5 bg-white-50 bg-center cover" style="background-image: url('{{ .Project.HeaderImage }}')"> <div class="project-header-img" style="background-image: url('{{ .Project.HeaderImage }}')">
<div class="flex justify-between pa3 link-normal b"> <div class="flex justify-between pa3">
<div class="flex g3"> <div class="flex g3">
{{ if .CanEdit }} {{ if .CanEdit }}
<div class="bg-transparent flex"> <div class="bg-transparent flex">
@ -21,18 +21,17 @@
</div> </div>
<div class="flex g3"> <div class="flex g3">
{{ with .PrimaryLinks }} {{ with .PrimaryLinks }}
<div class="bg-transparent flex"> <div class="project-links">
{{ range . }} {{ range . }}
<a class="ph3 pv2 flex items-center" href="{{ .Url }}">{{ .Name }}<span class="svgicon f6 ml2">{{ svg "arrow-right-up" }}</span></a> <a class="ph3 pv2 flex items-center" href="{{ .Url }}">{{ .Name }}<span class="svgicon f6 ml2">{{ svg "arrow-right-up" }}</span></a>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ with .SecondaryLinks }} {{ with .SecondaryLinks }}
<div class="bg-transparent flex items-center ph1"> <div class="project-links ph1">
{{ range . }} {{ range . }}
<a class="ph2 flex" href="{{ .Url }}" title="{{ .ServiceName }}{{ with .Username }} ({{ . }}){{ end }}">{{ svg (strjoin "logos/" .Icon) }}</a> <a class="ph2 flex" href="{{ .Url }}" title="{{ .ServiceName }}{{ with .Username }} ({{ . }}){{ end }}">{{ svg (strjoin "logos/" .Icon) }}</a>
{{ end }} {{ end }}
<div class="pv2">&#8203;</div><!-- make sure secondary links render at the right height despite SVG size -->
</div> </div>
{{ end }} {{ end }}
</div> </div>

View File

@ -155,6 +155,23 @@ func GetImg(file string) []byte {
return utils.Must1(io.ReadAll(img)) return utils.Must1(io.ReadAll(img))
} }
func ListImgsDir(dir string) []fs.DirEntry {
var imgs fs.ReadDirFS
if config.Config.DevConfig.LiveTemplates {
imgs = utils.DirFS("src/templates/img").(fs.ReadDirFS)
} else {
imgs = Imgs
dir = filepath.Join("img/", dir)
}
entries, err := imgs.ReadDir(dir)
if err != nil {
panic(err)
}
return entries
}
var controlCharRegex = regexp.MustCompile(`\p{Cc}`) var controlCharRegex = regexp.MustCompile(`\p{Cc}`)
var HMNTemplateFuncs = template.FuncMap{ var HMNTemplateFuncs = template.FuncMap{
@ -247,10 +264,7 @@ var HMNTemplateFuncs = template.FuncMap{
} }
}, },
"svg": func(name string) template.HTML { "svg": func(name string) template.HTML {
contents, err := Imgs.ReadFile(fmt.Sprintf("img/%s.svg", name)) contents := GetImg(fmt.Sprintf("%s.svg", name))
if err != nil {
panic("SVG not found: " + name)
}
return template.HTML(contents) return template.HTML(contents)
}, },
"static": func(filepath string) string { "static": func(filepath string) string {

View File

@ -228,6 +228,11 @@ type Link struct {
Primary bool `json:"primary"` Primary bool `json:"primary"`
} }
type Icon struct {
Name string `json:"name"`
Svg template.HTML `json:"svg"`
}
type Podcast struct { type Podcast struct {
Title string Title string
Description string Description string

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template"
"image" "image"
"io" "io"
"net/http" "net/http"
@ -372,6 +373,8 @@ type ProjectEditData struct {
APICheckUsernameUrl string APICheckUsernameUrl string
LogoMaxFileSize, HeaderMaxFileSize int LogoMaxFileSize, HeaderMaxFileSize int
AllLogos []templates.Icon
TextEditor templates.TextEditor TextEditor templates.TextEditor
} }
@ -396,7 +399,7 @@ func ProjectNew(c *RequestContext) ResponseData {
currentJam = hmndata.CurrentJam() currentJam = hmndata.CurrentJam()
if currentJam != nil { if currentJam != nil {
project.JamParticipation = []templates.ProjectJamParticipation{ project.JamParticipation = []templates.ProjectJamParticipation{
templates.ProjectJamParticipation{ {
JamName: currentJam.Name, JamName: currentJam.Name,
JamSlug: currentJam.Slug, JamSlug: currentJam.Slug,
Participating: true, Participating: true,
@ -416,6 +419,8 @@ func ProjectNew(c *RequestContext) ResponseData {
LogoMaxFileSize: ProjectLogoMaxFileSize, LogoMaxFileSize: ProjectLogoMaxFileSize,
HeaderMaxFileSize: ProjectHeaderMaxFileSize, HeaderMaxFileSize: ProjectHeaderMaxFileSize,
AllLogos: allLogos(),
TextEditor: templates.TextEditor{ TextEditor: templates.TextEditor{
MaxFileSize: AssetMaxSize(c.CurrentUser), MaxFileSize: AssetMaxSize(c.CurrentUser),
UploadUrl: c.UrlContext.BuildAssetUpload(), UploadUrl: c.UrlContext.BuildAssetUpload(),
@ -558,6 +563,8 @@ func ProjectEdit(c *RequestContext) ResponseData {
LogoMaxFileSize: ProjectLogoMaxFileSize, LogoMaxFileSize: ProjectLogoMaxFileSize,
HeaderMaxFileSize: ProjectHeaderMaxFileSize, HeaderMaxFileSize: ProjectHeaderMaxFileSize,
AllLogos: allLogos(),
TextEditor: templates.TextEditor{ TextEditor: templates.TextEditor{
MaxFileSize: AssetMaxSize(c.CurrentUser), MaxFileSize: AssetMaxSize(c.CurrentUser),
UploadUrl: c.UrlContext.BuildAssetUpload(), UploadUrl: c.UrlContext.BuildAssetUpload(),
@ -1102,3 +1109,16 @@ func CanEditProject(user *models.User, owners []*models.User) bool {
} }
return false return false
} }
func allLogos() []templates.Icon {
var logos []templates.Icon
logoEntries := templates.ListImgsDir("logos")
for _, logo := range logoEntries {
logos = append(logos, templates.Icon{
Name: logo.Name()[:len(logo.Name())-len(".svg")],
Svg: template.HTML(templates.GetImg(fmt.Sprintf("logos/%s", logo.Name()))),
})
}
return logos
}