Project page
This commit is contained in:
parent
98df5773a5
commit
6c53688e06
|
@ -3231,7 +3231,7 @@ code, .code {
|
|||
.w1 {
|
||||
width: 1rem; }
|
||||
|
||||
.w2, .project-carousel-container .project-carousel-button.active {
|
||||
.w2, .carousel-container .carousel-button.active {
|
||||
width: 2rem; }
|
||||
|
||||
.w3 {
|
||||
|
@ -9288,52 +9288,6 @@ span.icon-rss::before {
|
|||
background-color: #aa7d30;
|
||||
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 {
|
||||
color: black;
|
||||
color: var(--fg-font-color);
|
||||
|
@ -9546,3 +9500,49 @@ span.icon-rss::before {
|
|||
.timeline-modal .container {
|
||||
width: auto;
|
||||
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"})
|
||||
}
|
||||
|
||||
func TestProjectEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildProjectEdit("test", "foo"), RegexProjectEdit, map[string]string{"slug": "test"})
|
||||
}
|
||||
|
||||
func TestPodcast(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil)
|
||||
AssertSubdomain(t, BuildPodcast(""), "")
|
||||
|
|
|
@ -232,6 +232,14 @@ func BuildProjectNotApproved(slug string) string {
|
|||
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
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@include usevar(color, 'fg-font-color');
|
||||
@include usevar(background-color, 'card-background');
|
||||
|
|
|
@ -23,3 +23,4 @@
|
|||
@import 'showcase';
|
||||
@import 'streams';
|
||||
@import 'timeline';
|
||||
@import 'carousel';
|
||||
|
|
|
@ -216,16 +216,25 @@ func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData
|
|||
|
||||
func LinkToTemplate(link *models.Link) 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)
|
||||
if serviceUserData != "" {
|
||||
name = serviceUserData
|
||||
}
|
||||
if name == "" {
|
||||
name = link.Value
|
||||
}
|
||||
return Link{
|
||||
Key: link.Key,
|
||||
ServiceName: serviceName,
|
||||
ServiceUserData: serviceUserData,
|
||||
Name: name,
|
||||
Value: link.Value,
|
||||
Icon: serviceName,
|
||||
Url: link.Value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -64,10 +64,9 @@
|
|||
{{ if false }}
|
||||
<a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
|
||||
{{ end }}
|
||||
{{/* {% if showEditLink == True %} */}}
|
||||
{{/* {{ if false }}
|
||||
<a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ project.name }}"><span class="icon">0</span> Settings</a>
|
||||
{{ end }} */}}
|
||||
{{ if .Header.EditUrl }}
|
||||
<a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ .Project.Name }}"><span class="icon">0</span> Settings</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<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" }}
|
||||
<div class="content-block no-bg-image">
|
||||
{{ with .CarouselProjects }}
|
||||
<div class="project-carousel-container mw-100 mv2 mv3-ns margin-center">
|
||||
<div class="project-carousel pa3 h5 overflow-hidden bg--dim br2-ns">
|
||||
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
|
||||
<div class="carousel pa3 h5 overflow-hidden bg--dim br2-ns">
|
||||
<div class="dn db-l"> <!-- desktop carousel -->
|
||||
{{ 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">
|
||||
<a href="{{ $project.Url }}">
|
||||
<h3>{{ $project.Name }}</h3>
|
||||
</a>
|
||||
<div class="project-carousel-description">
|
||||
<div class="carousel-description">
|
||||
{{ $project.ParsedDescription }}
|
||||
</div>
|
||||
<div class="project-carousel-fade"></div>
|
||||
<div class="carousel-fade"></div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 order-0 order-1-ns">
|
||||
<a href="{{ $project.Url }}">
|
||||
|
@ -27,7 +27,7 @@
|
|||
</div>
|
||||
<div class="db dn-l"> <!-- mobile/tablet carousel -->
|
||||
{{ 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" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
@ -36,7 +36,7 @@
|
|||
<div class="flex justify-center pv2">
|
||||
{{ range $index, $project := . }}
|
||||
<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 }})"
|
||||
></div>
|
||||
{{ end }}
|
||||
|
@ -123,16 +123,16 @@
|
|||
<script>
|
||||
const numCarouselItems = {{ len .CarouselProjects }};
|
||||
|
||||
function activateCarouselProject(i) {
|
||||
const items = document.querySelectorAll('.project-carousel-item');
|
||||
function activateCarousel(i) {
|
||||
const items = document.querySelectorAll('.carousel-item');
|
||||
items.forEach(item => item.classList.remove('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[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[i].classList.add('active');
|
||||
}
|
||||
|
@ -140,12 +140,12 @@
|
|||
let carouselTimerCurrent = 0;
|
||||
const carouselTimer = setInterval(() => {
|
||||
const next = (carouselTimerCurrent + 1) % numCarouselItems;
|
||||
activateCarouselProject(next);
|
||||
activateCarousel(next);
|
||||
carouselTimerCurrent = next;
|
||||
}, 10000);
|
||||
|
||||
function carouselButtonClick(i) {
|
||||
activateCarouselProject(i);
|
||||
activateCarousel(i);
|
||||
clearInterval(carouselTimer);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -37,38 +37,7 @@
|
|||
</div>
|
||||
<div class="timeline">
|
||||
{{ range .TimelineItems }}
|
||||
{{ 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 }}
|
||||
{{ template "timeline_item.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -103,9 +72,9 @@
|
|||
{{ end }}
|
||||
|
||||
{{ range .ProfileUserLinks }}
|
||||
<div class="pair flex flex-wra[">
|
||||
<div class="pair flex flex-wrap">
|
||||
<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>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
|
|
@ -142,10 +142,14 @@ type User struct {
|
|||
|
||||
type Link struct {
|
||||
Key string
|
||||
ServiceName string
|
||||
ServiceUserData string
|
||||
Name string
|
||||
Value string
|
||||
Url string
|
||||
Icon string
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
Content template.HTML
|
||||
Class string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
|
|
|
@ -66,7 +66,7 @@ func ForumCategory(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
numPages := int(math.Ceil(float64(numThreads) / threadsPerPage))
|
||||
numPages := utils.IntMax(int(math.Ceil(float64(numThreads)/threadsPerPage)), 1)
|
||||
|
||||
page := 1
|
||||
pageString, hasPage := c.PathParams["page"]
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
|
@ -204,3 +208,259 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
}
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
path := req.URL.Path
|
||||
for _, route := range r.Routes {
|
||||
|
|
|
@ -5,10 +5,12 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/hmnurl"
|
||||
"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.
|
||||
routes.POST(hmnurl.RegexLoginAction, Login)
|
||||
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 {
|
||||
if c.CurrentProject.IsHMN() {
|
||||
return Index(c)
|
||||
} else {
|
||||
// TODO: Return the project landing page
|
||||
panic("route not implemented")
|
||||
return ProjectHomepage(c)
|
||||
}
|
||||
})
|
||||
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.RegexProjectIndex, ProjectIndex)
|
||||
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
|
||||
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
||||
|
||||
// NOTE(asaf): Any-project routes:
|
||||
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),
|
||||
ManifestoUrl: hmnurl.BuildManifesto(),
|
||||
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
EditUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
EditUrl: "",
|
||||
SearchActionUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
},
|
||||
Footer: templates.Footer{
|
||||
|
@ -349,6 +355,22 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, 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
|
||||
// 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) {
|
||||
|
|
Loading…
Reference in New Issue