Project page
This commit is contained in:
parent
98df5773a5
commit
6c53688e06
|
@ -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); }
|
||||||
|
|
|
@ -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(""), "")
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -23,3 +23,4 @@
|
||||||
@import 'showcase';
|
@import 'showcase';
|
||||||
@import 'streams';
|
@import 'streams';
|
||||||
@import 'timeline';
|
@import 'timeline';
|
||||||
|
@import 'carousel';
|
||||||
|
|
|
@ -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 := ""
|
||||||
if link.Name != nil {
|
/*
|
||||||
name = *link.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 {
|
||||||
|
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,
|
Name: name,
|
||||||
ServiceUserData: serviceUserData,
|
Icon: serviceName,
|
||||||
Name: name,
|
Url: link.Value,
|
||||||
Value: link.Value,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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> Settings</a>
|
||||||
<a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ project.name }}"><span class="icon">0</span> 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">
|
||||||
|
|
|
@ -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> — {{ 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 }}
|
||||||
|
|
|
@ -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 }}
|
|
@ -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>
|
||||||
|
|
|
@ -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> — {{ 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>
|
||||||
|
|
|
@ -141,11 +141,15 @@ type User struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Key string
|
Key string
|
||||||
ServiceName string
|
Name string
|
||||||
ServiceUserData string
|
Url string
|
||||||
Name string
|
Icon string
|
||||||
Value string
|
}
|
||||||
|
|
||||||
|
type Notice struct {
|
||||||
|
Content template.HTML
|
||||||
|
Class string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Reference in New Issue