Project page

This commit is contained in:
Asaf Gartner 2021-07-08 10:40:30 +03:00
parent 98df5773a5
commit 6c53688e06
17 changed files with 609 additions and 199 deletions

View File

@ -3231,7 +3231,7 @@ code, .code {
.w1 { .w1 {
width: 1rem; } width: 1rem; }
.w2, .project-carousel-container .project-carousel-button.active { .w2, .carousel-container .carousel-button.active {
width: 2rem; } width: 2rem; }
.w3 { .w3 {
@ -9288,52 +9288,6 @@ span.icon-rss::before {
background-color: #aa7d30; background-color: #aa7d30;
background-color: var(--notice-lts-reqd-color); } background-color: var(--notice-lts-reqd-color); }
.project-carousel-container {
width: 50rem; }
.project-carousel-container .project-carousel {
box-sizing: content-box;
position: relative;
height: 12rem; }
@media screen and (min-width: 60em) {
.project-carousel-container .project-carousel {
height: 16rem; } }
.project-carousel-container .project-carousel-item {
position: absolute;
top: 0;
left: 0; }
.project-carousel-container .project-carousel-item:not(.active) {
display: none; }
.project-carousel-container .project-carousel-item br {
line-height: 0.6em; }
.project-carousel-container .project-carousel-description {
max-height: 14rem;
overflow: hidden; }
.project-carousel-container .project-carousel-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 30px;
background: linear-gradient( rgba(240, 240, 240, 0) , #f0f0f0 );
background: linear-gradient( var(--dim-background-transparent) , var(--dim-background) ); }
.project-carousel-container .project-carousel-item-small:not(.active) {
display: none; }
.project-carousel-container .project-carousel-button {
border: 1px solid;
border-color: #999;
border-color: var(--dimmer-color);
cursor: pointer;
transition: all 100ms ease-in-out; }
.project-carousel-container .project-carousel-button:hover {
background-color: #bbb;
background-color: var(--dimmest-color); }
.project-carousel-container .project-carousel-button.active {
border-color: #666;
border-color: var(--theme-color); }
.project-carousel-container .project-carousel-button.active:hover {
background-color: #ccc;
background-color: var(--theme-color-dimmest); }
.project-card { .project-card {
color: black; color: black;
color: var(--fg-font-color); color: var(--fg-font-color);
@ -9546,3 +9500,49 @@ span.icon-rss::before {
.timeline-modal .container { .timeline-modal .container {
width: auto; width: auto;
max-height: calc(100vh - 2rem); } } max-height: calc(100vh - 2rem); } }
.carousel-container {
width: 50rem; }
.carousel-container .carousel {
box-sizing: content-box;
position: relative; }
.carousel-container .carousel-item {
position: absolute;
top: 0;
left: 0; }
.carousel-container .carousel-item:not(.active) {
display: none; }
.carousel-container .carousel-item br {
line-height: 0.6em; }
.carousel-container .carousel-description {
max-height: 14rem;
overflow: hidden; }
.carousel-container .carousel-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 30px;
background: linear-gradient( rgba(240, 240, 240, 0) , #f0f0f0 );
background: linear-gradient( var(--dim-background-transparent) , var(--dim-background) ); }
.carousel-container .carousel-item-small {
position: absolute;
top: 0;
left: 0; }
.carousel-container .carousel-item-small:not(.active) {
display: none; }
.carousel-container .carousel-button {
border: 1px solid;
border-color: #999;
border-color: var(--dimmer-color);
cursor: pointer;
transition: all 100ms ease-in-out; }
.carousel-container .carousel-button:hover {
background-color: #bbb;
background-color: var(--dimmest-color); }
.carousel-container .carousel-button.active {
border-color: #666;
border-color: var(--theme-color); }
.carousel-container .carousel-button.active:hover {
background-color: #ccc;
background-color: var(--theme-color-dimmest); }

View File

@ -112,6 +112,10 @@ func TestProjectNotApproved(t *testing.T) {
AssertRegexMatch(t, BuildProjectNotApproved("test"), RegexProjectNotApproved, map[string]string{"slug": "test"}) AssertRegexMatch(t, BuildProjectNotApproved("test"), RegexProjectNotApproved, map[string]string{"slug": "test"})
} }
func TestProjectEdit(t *testing.T) {
AssertRegexMatch(t, BuildProjectEdit("test", "foo"), RegexProjectEdit, map[string]string{"slug": "test"})
}
func TestPodcast(t *testing.T) { func TestPodcast(t *testing.T) {
AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil) AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil)
AssertSubdomain(t, BuildPodcast(""), "") AssertSubdomain(t, BuildPodcast(""), "")

View File

@ -232,6 +232,14 @@ func BuildProjectNotApproved(slug string) string {
return Url(fmt.Sprintf("/p/%s", slug), nil) return Url(fmt.Sprintf("/p/%s", slug), nil)
} }
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
func BuildProjectEdit(slug string, section string) string {
defer CatchPanic()
return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section)
}
/* /*
* Podcast * Podcast
*/ */

View File

@ -0,0 +1,74 @@
.carousel-container {
width: 50rem;
.carousel {
box-sizing: content-box;
position: relative;
// height: 12rem;
@media #{$breakpoint-large} {
// height: $height-5;
}
}
.carousel-item {
position: absolute;
top: 0;
left: 0;
&:not(.active) {
display: none;
}
br {
line-height: 0.6em;
}
}
.carousel-description {
max-height: 14rem;
overflow: hidden;
}
.carousel-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 30px;
@include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")")
}
.carousel-item-small {
position: absolute;
top: 0;
left: 0;
&:not(.active) {
display: none;
}
}
.carousel-button {
border: 1px solid;
@include usevar(border-color, dimmer-color);
cursor: pointer;
transition: all 100ms ease-in-out;
&:hover {
@include usevar(background-color, dimmest-color);
}
&.active {
@include usevar(border-color, theme-color);
@extend .w2;
&:hover {
@include usevar(background-color, theme-color-dimmest);
}
}
}
}

View File

@ -93,76 +93,6 @@
@include usevar(background-color, notice-lts-reqd-color); @include usevar(background-color, notice-lts-reqd-color);
} }
.project-carousel-container {
width: 50rem;
.project-carousel {
box-sizing: content-box;
position: relative;
height: 12rem;
@media #{$breakpoint-large} {
height: $height-5;
}
}
.project-carousel-item {
position: absolute;
top: 0;
left: 0;
&:not(.active) {
display: none;
}
br {
line-height: 0.6em;
}
}
.project-carousel-description {
max-height: 14rem;
overflow: hidden;
}
.project-carousel-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 30px;
@include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")")
}
.project-carousel-item-small {
&:not(.active) {
display: none;
}
}
.project-carousel-button {
border: 1px solid;
@include usevar(border-color, dimmer-color);
cursor: pointer;
transition: all 100ms ease-in-out;
&:hover {
@include usevar(background-color, dimmest-color);
}
&.active {
@include usevar(border-color, theme-color);
@extend .w2;
&:hover {
@include usevar(background-color, theme-color-dimmest);
}
}
}
}
.project-card { .project-card {
@include usevar(color, 'fg-font-color'); @include usevar(color, 'fg-font-color');
@include usevar(background-color, 'card-background'); @include usevar(background-color, 'card-background');

View File

@ -23,3 +23,4 @@
@import 'showcase'; @import 'showcase';
@import 'streams'; @import 'streams';
@import 'timeline'; @import 'timeline';
@import 'carousel';

View File

@ -216,16 +216,25 @@ func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData
func LinkToTemplate(link *models.Link) Link { func LinkToTemplate(link *models.Link) Link {
name := "" name := ""
/*
// NOTE(asaf): While Name and Key are separate things, Name is almost always the same as Key in the db, which looks weird.
// So we're just going to ignore Name until we decide it's worth reusing.
if link.Name != nil { if link.Name != nil {
name = *link.Name name = *link.Name
} }
*/
serviceName, serviceUserData := ParseKnownServicesForLink(link) serviceName, serviceUserData := ParseKnownServicesForLink(link)
if serviceUserData != "" {
name = serviceUserData
}
if name == "" {
name = link.Value
}
return Link{ return Link{
Key: link.Key, Key: link.Key,
ServiceName: serviceName,
ServiceUserData: serviceUserData,
Name: name, Name: name,
Value: link.Value, Icon: serviceName,
Url: link.Value,
} }
} }

View File

@ -64,10 +64,9 @@
{{ if false }} {{ if false }}
<a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a> <a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
{{ end }} {{ end }}
{{/* {% if showEditLink == True %} */}} {{ if .Header.EditUrl }}
{{/* {{ if false }} <a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ .Project.Name }}"><span class="icon">0</span>&nbsp;Settings</a>
<a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ project.name }}"><span class="icon">0</span>&nbsp;Settings</a> {{ end }}
{{ end }} */}}
</div> </div>
</div> </div>
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ .Header.SearchActionUrl }}" target="_blank"> <form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ .Header.SearchActionUrl }}" target="_blank">

View File

@ -0,0 +1,33 @@
{{ if timelinepostitem . }}
<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}">
<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
<div class="timeline-info overflow-hidden">
{{ template "breadcrumbs.html" .Breadcrumbs }}
<div class="title f5 b nowrap truncate"><span>{{ .TypeTitle }}</span>: <a href="{{ .Url }}" class="title-text normal">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
</div>
</div>
{{ else if timelinesnippetitem . }}
<div class="timeline-item flex flex-column pa3 mb2 br3 {{ .Class }}">
<div class="timeline-user-info mb2 flex items-center">
<img class="avatar-icon lite mr2" src="{{ .OwnerAvatarUrl }}"/>
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
<a class="datetime tr" style="flex: 1 1 auto;" href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
</div>
<p class="timeline-snippet-title mb2">{{ .Description }}</p>
<div class="timeline-content-box {{ if snippetyoutube . }}youtube{{ end }}">
{{ if snippetvideo . }}
<video src="{{ .AssetUrl }}" preload="metadata" controls />
{{ else if snippetimage . }}
<img src="{{ .AssetUrl }}" />
{{ else if snippetaudio . }}
<audio src="{{ .AssetUrl }}" controls />
{{ else if snippetyoutube .}}
<iframe src="https://www.youtube-nocookie.com/embed/{{ .YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -0,0 +1,108 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
{{ range .Screenshots }}
<link rel="preload" href="{{ . }}" as="image">
{{ end }}
{{ end }}
{{ define "content" }}
<div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden">
{{ range .Notices }}
<div class="content-block notice notice-{{ .Class }}">
{{ .Content }}
</div>
{{ end }}
{{ with .Screenshots }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns">
<div class="dn db-l">
{{ range $index, $screenshot := . }}
<div class="carousel-item aspect-ratio--object bg--dim {{ if eq $index 0 }}active{{ end }}">
<div class="w-100 h-100" style="background:url('{{ $screenshot }}') no-repeat center / contain"></div>
</div>
{{ end }}
</div>
<div class="db dn-l">
{{ range $index, $screenshot := . }}
<div class="carousel-item-small aspect-ratio--object {{ if eq $index 0 }}active{{ end }}">
<div class="w-100 h-100" style="background:url('{{ $screenshot }}') no-repeat center / contain"></div>
</div>
{{ end }}
</div>
</div>
<div class="flex justify-center pv2">
{{ range $index, $screenshot := . }}
<div class="carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}" onclick="carouselButtonClick({{ $index }})"></div>
{{ end }}
</div>
</div>
{{ end }}
<div class="description">
{{ .Project.ParsedDescription }}
</div>
{{ with .RecentActivity }}
<div class="content-block timeline-container ph3 ph0-ns mv4">
<h2>Recent Activity</h2>
<div class="timeline">
{{ range . }}
{{ template "timeline_item.html" . }}
{{ end }}
</div>
</div>
{{ end }}
{{/* TODO(asaf): Add timeline items for project */}}
</div>
<div class="sidebar flex-shrink-0 mw6 w-30-l self-center self-start-l mh3 mh0-ns ml3-l overflow-hidden">
<div class="content-block">
<img alt="{{ .Project.Name }} Logo" class="br3" src="{{ .Project.Logo }}" />
<div class="mv3 relative">
<div class="mb3">
{{ range $i, $owner := .Owners }}
<div class="flex mv3 items-center {{ if eq $i 0 }}mt2{{ end }}">
<img class="avatar-icon mr2" src="{{ $owner.AvatarUrl }}" />
<a class="user-link" href="{{ $owner.ProfileUrl }}">{{ $owner.Name }}</a>
</div>
{{ end }}
</div>
{{ range .ProjectLinks }}
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">{{ .Key }}</div>
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
</div>
{{ end }}
</div>
</div>
</div>
</div>
<script>
const numCarouselItems = {{ len .Screenshots }};
function activateCarouselItem(i) {
const items = document.querySelectorAll('.carousel-item');
items.forEach(item => item.classList.remove('active'));
items[i].classList.add('active');
const smallItems = document.querySelectorAll('.carousel-item-small');
smallItems.forEach(item => item.classList.remove('active'));
smallItems[i].classList.add('active');
const buttons = document.querySelectorAll('.carousel-button');
buttons.forEach(button => button.classList.remove('active'));
buttons[i].classList.add('active');
}
let carouselTimerCurrent = 0;
const carouselTimer = setInterval(() => {
const next = (carouselTimerCurrent + 1) % numCarouselItems;
activateCarouselItem(next);
carouselTimerCurrent = next;
}, 10000);
function carouselButtonClick(i) {
activateCarouselItem(i);
clearInterval(carouselTimer);
}
</script>
{{ end }}

View File

@ -3,19 +3,19 @@
{{ define "content" }} {{ define "content" }}
<div class="content-block no-bg-image"> <div class="content-block no-bg-image">
{{ with .CarouselProjects }} {{ with .CarouselProjects }}
<div class="project-carousel-container mw-100 mv2 mv3-ns margin-center"> <div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="project-carousel pa3 h5 overflow-hidden bg--dim br2-ns"> <div class="carousel pa3 h5 overflow-hidden bg--dim br2-ns">
<div class="dn db-l"> <!-- desktop carousel --> <div class="dn db-l"> <!-- desktop carousel -->
{{ range $index, $project := . }} {{ range $index, $project := . }}
<div class="project-carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}"> <div class="carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}">
<div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center"> <div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
<a href="{{ $project.Url }}"> <a href="{{ $project.Url }}">
<h3>{{ $project.Name }}</h3> <h3>{{ $project.Name }}</h3>
</a> </a>
<div class="project-carousel-description"> <div class="carousel-description">
{{ $project.ParsedDescription }} {{ $project.ParsedDescription }}
</div> </div>
<div class="project-carousel-fade"></div> <div class="carousel-fade"></div>
</div> </div>
<div class="flex-shrink-0 order-0 order-1-ns"> <div class="flex-shrink-0 order-0 order-1-ns">
<a href="{{ $project.Url }}"> <a href="{{ $project.Url }}">
@ -27,7 +27,7 @@
</div> </div>
<div class="db dn-l"> <!-- mobile/tablet carousel --> <div class="db dn-l"> <!-- mobile/tablet carousel -->
{{ range $index, $project := . }} {{ range $index, $project := . }}
<div class="project-carousel-item-small {{ if eq $index 0 }}active{{ end }}"> <div class="carousel-item-small {{ if eq $index 0 }}active{{ end }}">
{{ template "project_card.html" projectcarddata $project "h-100" }} {{ template "project_card.html" projectcarddata $project "h-100" }}
</div> </div>
{{ end }} {{ end }}
@ -36,7 +36,7 @@
<div class="flex justify-center pv2"> <div class="flex justify-center pv2">
{{ range $index, $project := . }} {{ range $index, $project := . }}
<div <div
class="project-carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}" class="carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}"
onclick="carouselButtonClick({{ $index }})" onclick="carouselButtonClick({{ $index }})"
></div> ></div>
{{ end }} {{ end }}
@ -123,16 +123,16 @@
<script> <script>
const numCarouselItems = {{ len .CarouselProjects }}; const numCarouselItems = {{ len .CarouselProjects }};
function activateCarouselProject(i) { function activateCarousel(i) {
const items = document.querySelectorAll('.project-carousel-item'); const items = document.querySelectorAll('.carousel-item');
items.forEach(item => item.classList.remove('active')); items.forEach(item => item.classList.remove('active'));
items[i].classList.add('active'); items[i].classList.add('active');
const smallItems = document.querySelectorAll('.project-carousel-item-small'); const smallItems = document.querySelectorAll('.carousel-item-small');
smallItems.forEach(item => item.classList.remove('active')); smallItems.forEach(item => item.classList.remove('active'));
smallItems[i].classList.add('active'); smallItems[i].classList.add('active');
const buttons = document.querySelectorAll('.project-carousel-button'); const buttons = document.querySelectorAll('.carousel-button');
buttons.forEach(button => button.classList.remove('active')); buttons.forEach(button => button.classList.remove('active'));
buttons[i].classList.add('active'); buttons[i].classList.add('active');
} }
@ -140,12 +140,12 @@
let carouselTimerCurrent = 0; let carouselTimerCurrent = 0;
const carouselTimer = setInterval(() => { const carouselTimer = setInterval(() => {
const next = (carouselTimerCurrent + 1) % numCarouselItems; const next = (carouselTimerCurrent + 1) % numCarouselItems;
activateCarouselProject(next); activateCarousel(next);
carouselTimerCurrent = next; carouselTimerCurrent = next;
}, 10000); }, 10000);
function carouselButtonClick(i) { function carouselButtonClick(i) {
activateCarouselProject(i); activateCarousel(i);
clearInterval(carouselTimer); clearInterval(carouselTimer);
} }
</script> </script>

View File

@ -37,38 +37,7 @@
</div> </div>
<div class="timeline"> <div class="timeline">
{{ range .TimelineItems }} {{ range .TimelineItems }}
{{ if timelinepostitem . }} {{ template "timeline_item.html" . }}
<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}">
<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
<div class="timeline-info overflow-hidden">
{{ template "breadcrumbs.html" .Breadcrumbs }}
<div class="title f5 b nowrap truncate"><span>{{ .TypeTitle }}</span>: <a href="{{ .Url }}" class="title-text normal">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
</div>
</div>
{{ else if timelinesnippetitem . }}
<div class="timeline-item flex flex-column pa3 mb2 br3 {{ .Class }}">
<div class="timeline-user-info mb2 flex items-center">
<img class="avatar-icon lite mr2" src="{{ .OwnerAvatarUrl }}"/>
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
<a class="datetime tr" style="flex: 1 1 auto;" href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
</div>
<p class="timeline-snippet-title mb2">{{ .Description }}</p>
<div class="timeline-content-box {{ if snippetyoutube . }}youtube{{ end }}">
{{ if snippetvideo . }}
<video src="{{ .AssetUrl }}" preload="metadata" controls />
{{ else if snippetimage . }}
<img src="{{ .AssetUrl }}" />
{{ else if snippetaudio . }}
<audio src="{{ .AssetUrl }}" controls />
{{ else if snippetyoutube .}}
<iframe src="https://www.youtube-nocookie.com/embed/{{ .YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>
{{ end }}
</div>
</div>
{{ end }}
{{ end }} {{ end }}
</div> </div>
</div> </div>
@ -103,9 +72,9 @@
{{ end }} {{ end }}
{{ range .ProfileUserLinks }} {{ range .ProfileUserLinks }}
<div class="pair flex flex-wra["> <div class="pair flex flex-wrap">
<div class="key flex-auto mr1">{{ .Key }}</div> <div class="key flex-auto mr1">{{ .Key }}</div>
<div class="value projectlink"><a class="external" href="{{ .Value }}" ><span class="icon-{{ .ServiceName }}"></span> {{ if .ServiceUserData }}{{ .ServiceUserData }}{{ else }}{{ .Value }}{{ end }}</a></div> <div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
</div> </div>
{{ end }} {{ end }}
</div> </div>

View File

@ -142,10 +142,14 @@ type User struct {
type Link struct { type Link struct {
Key string Key string
ServiceName string
ServiceUserData string
Name string Name string
Value string Url string
Icon string
}
type Notice struct {
Content template.HTML
Class string
} }
type Session struct { type Session struct {

View File

@ -66,7 +66,7 @@ func ForumCategory(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
numPages := int(math.Ceil(float64(numThreads) / threadsPerPage)) numPages := utils.IntMax(int(math.Ceil(float64(numThreads)/threadsPerPage)), 1)
page := 1 page := 1
pageString, hasPage := c.PathParams["page"] pageString, hasPage := c.PathParams["page"]

View File

@ -1,10 +1,14 @@
package website package website
import ( import (
"errors"
"fmt"
"html/template"
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
@ -204,3 +208,259 @@ func ProjectIndex(c *RequestContext) ResponseData {
} }
return res return res
} }
type ProjectHomepageData struct {
templates.BaseData
Project templates.Project
Owners []templates.User
Notices []templates.Notice
Screenshots []string
ProjectLinks []templates.Link
Licenses []templates.Link
RecentActivity []templates.TimelineItem
}
func ProjectHomepage(c *RequestContext) ResponseData {
maxRecentActivity := 15
var project *models.Project
if c.CurrentProject.IsHMN() {
slug, hasSlug := c.PathParams["slug"]
if hasSlug && slug != "" {
slug = strings.ToLower(slug)
if slug == models.HMNProjectSlug {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
c.Perf.StartBlock("SQL", "Fetching project by slug")
type projectQuery struct {
Project models.Project `db:"Project"`
}
projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
WHERE
LOWER(project.slug) = $1
`,
slug,
)
c.Perf.EndBlock()
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
}
}
project = &projectQueryResult.(*projectQuery).Project
if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired {
return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther)
}
}
} else {
project = c.CurrentProject
}
if project == nil {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetching project owners")
type ownerQuery struct {
Owner models.User `db:"auth_user"`
}
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
`
SELECT $columns
FROM
auth_user
INNER JOIN auth_user_groups AS user_groups ON auth_user.id = user_groups.user_id
INNER JOIN handmade_project_groups AS project_groups ON user_groups.group_id = project_groups.group_id
WHERE
project_groups.project_id = $1
`,
project.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch owners for project"))
}
ownerQueryData := ownerQueryResult.ToSlice()
c.Perf.EndBlock()
canView := false
canEdit := false
if c.CurrentUser != nil {
if c.CurrentUser.IsSuperuser {
canView = true
canEdit = true
} else {
for _, ownerRow := range ownerQueryData {
if ownerRow.(*ownerQuery).Owner.ID == c.CurrentUser.ID {
canView = true
canEdit = true
break
}
}
}
}
if !canView {
if project.Flags == 0 {
for _, lc := range models.VisibleProjectLifecycles {
if project.Lifecycle == lc {
canView = true
break
}
}
}
}
if !canView {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetching screenshots")
type screenshotQuery struct {
Filename string `db:"screenshot.file"`
}
screenshotQueryResult, err := db.Query(c.Context(), c.Conn, screenshotQuery{},
`
SELECT $columns
FROM
handmade_imagefile AS screenshot
INNER JOIN handmade_project_screenshots ON screenshot.id = handmade_project_screenshots.imagefile_id
WHERE
handmade_project_screenshots.project_id = $1
`,
project.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project links")
type projectLinkQuery struct {
Link models.Link `db:"link"`
}
projectLinkResult, err := db.Query(c.Context(), c.Conn, projectLinkQuery{},
`
SELECT $columns
FROM
handmade_links as link
WHERE
link.project_id = $1
ORDER BY link.ordering ASC
`,
project.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project timeline")
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Author models.User `db:"author"`
}
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN auth_user AS author ON author.id = post.author_id
WHERE
post.project_id = $1
ORDER BY post.postdate DESC
LIMIT $2
`,
project.ID,
maxRecentActivity,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts"))
}
c.Perf.EndBlock()
var projectHomepageData ProjectHomepageData
projectHomepageData.BaseData = getBaseData(c)
if canEdit {
projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "")
}
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
for _, ownerRow := range ownerQueryData {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(&ownerRow.(*ownerQuery).Owner, c.Theme))
}
if project.Flags == 1 {
hiddenNotice := templates.Notice{
Class: "hidden",
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
}
projectHomepageData.Notices = append(projectHomepageData.Notices, hiddenNotice)
}
if project.Lifecycle != models.ProjectLifecycleActive {
var lifecycleNotice templates.Notice
switch project.Lifecycle {
case models.ProjectLifecycleUnapproved:
lifecycleNotice.Class = "unapproved"
lifecycleNotice.Content = template.HTML(fmt.Sprintf(
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
hmnurl.BuildProjectEdit(project.Slug, "submit"),
))
case models.ProjectLifecycleApprovalRequired:
lifecycleNotice.Class = "unapproved"
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.")
case models.ProjectLifecycleHiatus:
lifecycleNotice.Class = "hiatus"
lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.")
case models.ProjectLifecycleDead:
lifecycleNotice.Class = "dead"
lifecycleNotice.Content = template.HTML("NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.")
case models.ProjectLifecycleLTSRequired:
lifecycleNotice.Class = "lts-reqd"
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.")
case models.ProjectLifecycleLTS:
lifecycleNotice.Class = "lts"
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
}
projectHomepageData.Notices = append(projectHomepageData.Notices, lifecycleNotice)
}
for _, screenshot := range screenshotQueryResult.ToSlice() {
projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
}
for _, link := range projectLinkResult.ToSlice() {
projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
}
for _, post := range postQueryResult.ToSlice() {
projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
lineageBuilder,
&post.(*postQuery).Post,
&post.(*postQuery).Thread,
project,
nil,
&post.(*postQuery).Author,
c.Theme,
))
}
var res ResponseData
err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
}
return res
}

View File

@ -38,13 +38,6 @@ type RouteBuilder struct {
type Handler func(c *RequestContext) ResponseData type Handler func(c *RequestContext) ResponseData
func WrapStdHandler(h http.Handler) Handler {
return func(c *RequestContext) (res ResponseData) {
h.ServeHTTP(&res, c.Req)
return res
}
}
type Middleware func(h Handler) Handler type Middleware func(h Handler) Handler
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) { func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
@ -70,10 +63,6 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
rb.Handle([]string{http.MethodPost}, regex, h) rb.Handle([]string{http.MethodPost}, regex, h)
} }
func (rb *RouteBuilder) StdHandler(regex *regexp.Regexp, h http.Handler) {
rb.Handle([]string{""}, regex, WrapStdHandler(h))
}
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path path := req.URL.Path
for _, route := range r.Routes { for _, route := range r.Routes {

View File

@ -5,10 +5,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/logging"
@ -117,16 +119,19 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware. // TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
routes.POST(hmnurl.RegexLoginAction, Login) routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogoutAction, Logout) routes.GET(hmnurl.RegexLogoutAction, Logout)
routes.StdHandler(hmnurl.RegexPublic,
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))), routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
) var res ResponseData
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
AddCORSHeaders(c, &res)
return res
})
mainRoutes.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { mainRoutes.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
if c.CurrentProject.IsHMN() { if c.CurrentProject.IsHMN() {
return Index(c) return Index(c)
} else { } else {
// TODO: Return the project landing page return ProjectHomepage(c)
panic("route not implemented")
} }
}) })
staticPages.GET(hmnurl.RegexManifesto, Manifesto) staticPages.GET(hmnurl.RegexManifesto, Manifesto)
@ -145,6 +150,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexSnippet, Snippet) mainRoutes.GET(hmnurl.RegexSnippet, Snippet)
mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
// NOTE(asaf): Any-project routes: // NOTE(asaf): Any-project routes:
mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
@ -198,7 +204,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug), LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(), ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf) EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
EditUrl: hmnurl.BuildHomepage(), // TODO(asaf) EditUrl: "",
SearchActionUrl: hmnurl.BuildHomepage(), // TODO(asaf) SearchActionUrl: hmnurl.BuildHomepage(), // TODO(asaf)
}, },
Footer: templates.Footer{ Footer: templates.Footer{
@ -349,6 +355,22 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
return true, ResponseData{} return true, ResponseData{}
} }
func AddCORSHeaders(c *RequestContext, res *ResponseData) {
parsed, err := url.Parse(config.Config.BaseUrl)
if err != nil {
c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
return
}
origin := ""
origins, found := c.Req.Header["Origin"]
if found {
origin = origins[0]
}
if strings.HasSuffix(origin, parsed.Host) {
res.Header().Add("Access-Control-Allow-Origin", origin)
}
}
// Given a session id, fetches user data from the database. Will return nil if // Given a session id, fetches user data from the database. Will return nil if
// the user cannot be found, and will only return an error if it's serious. // the user cannot be found, and will only return an error if it's serious.
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) { func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {