Add projects / following UI to home page

This commit is contained in:
Ben Visness 2024-06-21 20:13:20 -05:00
parent 7144db58ed
commit 86825f1c09
23 changed files with 374 additions and 188 deletions

View File

@ -7189,8 +7189,10 @@ code {
--notice-warn-color: #aa7d30; --notice-warn-color: #aa7d30;
--notice-failure-color: #b42222; --notice-failure-color: #b42222;
--spoiler-border: #aaa; --spoiler-border: #aaa;
--site-width: 80rem; --site-width: 54rem;
--site-width-narrow: 60rem; --site-width-narrow: 40rem;
--avatar-size-small: 1.5rem;
--avatar-size-normal: 2.5rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@ -7756,7 +7758,6 @@ pre,
} }
.svgicon svg { .svgicon svg {
fill: currentColor; fill: currentColor;
stroke: currentColor;
width: 1em; width: 1em;
height: 1em; height: 1em;
overflow: visible; overflow: visible;
@ -7764,6 +7765,10 @@ pre,
.svgicon:not(.svgicon-nofix) svg { .svgicon:not(.svgicon-nofix) svg {
transform: translate(0px, 0.1em); transform: translate(0px, 0.1em);
} }
.svgicon-lite svg {
fill: currentColor;
overflow: visible;
}
.sr { .sr {
border: 0; border: 0;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
@ -8467,6 +8472,8 @@ header.old .submenu > a {
} }
header { header {
background-color: var(--bg-3); background-color: var(--bg-3);
border-bottom-style: solid;
border-bottom-width: 1px;
} }
header .hmn-logo { header .hmn-logo {
font-family: "MohaveHMN", sans-serif; font-family: "MohaveHMN", sans-serif;
@ -8478,10 +8485,10 @@ header .hmn-logo {
header .menu-chevron { header .menu-chevron {
display: inline-block; display: inline-block;
margin-left: var(--spacing-extra-small); margin-left: var(--spacing-extra-small);
font-size: var(--font-size-7);
} }
header .avatar { header .avatar {
width: 1.8rem; width: 1.8rem;
height: 1.8rem;
} }
header .header-nav > a, header .header-nav > a,
header .header-nav > .root-item > a { header .header-nav > .root-item > a {
@ -8495,6 +8502,9 @@ header .header-nav .submenu {
z-index: 100; z-index: 100;
min-width: 8rem; min-width: 8rem;
background-color: var(--card-background); background-color: var(--card-background);
border-style: solid;
border-width: 1px;
border-top-width: 0;
} }
header .header-nav .submenu > a { header .header-nav .submenu > a {
padding: var(--spacing-small) var(--spacing-medium); padding: var(--spacing-small) var(--spacing-medium);
@ -8880,10 +8890,19 @@ code .ss,
/* src/rawdata/scss/timeline.css */ /* src/rawdata/scss/timeline.css */
.avatar { .avatar {
object-fit: cover; object-fit: cover;
border-radius: 100%;
overflow: hidden; overflow: hidden;
background-color: var(--dimmest-color); background-color: var(--dimmest-color);
flex-shrink: 0; flex-shrink: 0;
border: none;
width: var(--avatar-size-normal);
height: var(--avatar-size-normal);
}
.avatar.avatar-user {
border-radius: 999px;
}
.avatar.avatar-small {
width: var(--avatar-size-small);
height: var(--avatar-size-small);
} }
.timeline-item { .timeline-item {
background-color: var(--card-background); background-color: var(--card-background);

View File

@ -44,6 +44,8 @@ func FetchSnippets(
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
isFiltering := len(q.IDs) > 0 || len(q.Tags) > 0 || len(q.ProjectIDs) > 0
var tagSnippetIDs []int var tagSnippetIDs []int
if len(q.Tags) > 0 { if len(q.Tags) > 0 {
// Get snippet IDs with this tag, then use that in the main query // Get snippet IDs with this tag, then use that in the main query
@ -103,15 +105,17 @@ func FetchSnippets(
allSnippetIDs = append(allSnippetIDs, q.IDs...) allSnippetIDs = append(allSnippetIDs, q.IDs...)
allSnippetIDs = append(allSnippetIDs, tagSnippetIDs...) allSnippetIDs = append(allSnippetIDs, tagSnippetIDs...)
allSnippetIDs = append(allSnippetIDs, projectSnippetIDs...) allSnippetIDs = append(allSnippetIDs, projectSnippetIDs...)
if len(allSnippetIDs) == 0 { if isFiltering && len(allSnippetIDs) == 0 {
// We already managed to filter out all snippets, and all further // We already managed to filter out all snippets, and all further
// parts of this query are more filters, so we can just fail everything // parts of this query are more filters, so we can just fail everything
// else from right here. // else from right here.
qb.Add(`AND FALSE`) qb.Add(`AND FALSE`)
} else if len(q.OwnerIDs) > 0 { } else if len(allSnippetIDs) > 0 && len(q.OwnerIDs) > 0 {
qb.Add(`AND (snippet.id = ANY ($?) OR snippet.owner_id = ANY ($?))`, allSnippetIDs, q.OwnerIDs) qb.Add(`AND (snippet.id = ANY ($?) OR snippet.owner_id = ANY ($?))`, allSnippetIDs, q.OwnerIDs)
} else { } else {
qb.Add(`AND snippet.id = ANY ($?)`, allSnippetIDs) if len(allSnippetIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, allSnippetIDs)
}
if len(q.OwnerIDs) > 0 { if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs) qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
} }

7
src/models/follow.go Normal file
View File

@ -0,0 +1,7 @@
package models
type Follow struct {
UserID int `db:"user_id"`
FollowingUserID *int `db:"following_user_id"`
FollowingProjectID *int `db:"following_project_id"`
}

View File

@ -441,7 +441,7 @@ pre,
.w7-ns { .w7-ns {
width: var(--width-7); width: var(--width-7);
} }
.w8-ns { .w8-ns {
width: var(--width-8); width: var(--width-8);
} }
@ -527,7 +527,7 @@ pre,
.w7-m { .w7-m {
width: var(--width-7); width: var(--width-7);
} }
.w8-m { .w8-m {
width: var(--width-8); width: var(--width-8);
} }
@ -609,7 +609,7 @@ pre,
.w7-l { .w7-l {
width: var(--width-7); width: var(--width-7);
} }
.w8-l { .w8-l {
width: var(--width-8); width: var(--width-8);
} }
@ -683,10 +683,14 @@ pre,
} }
} }
/*
TODO(redesign): It's really unfortunate that we rely on text stuff so much...it
makes all our SVGs fuzzy. Evaluate the places we use this and see if we can use the
lite variant instead.
*/
.svgicon { .svgicon {
svg { svg {
fill: currentColor; fill: currentColor;
stroke: currentColor;
width: 1em; width: 1em;
height: 1em; height: 1em;
overflow: visible; overflow: visible;
@ -697,6 +701,13 @@ pre,
} }
} }
.svgicon-lite {
svg {
fill: currentColor;
overflow: visible;
}
}
.sr { .sr {
border: 0; border: 0;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);

View File

@ -115,6 +115,8 @@ header.old {
header { header {
background-color: var(--bg-3); background-color: var(--bg-3);
border-bottom-style: solid;
border-bottom-width: 1px;
.hmn-logo { .hmn-logo {
font-family: 'MohaveHMN', sans-serif; font-family: 'MohaveHMN', sans-serif;
@ -128,11 +130,11 @@ header {
/* ensure that it also has .svgicon */ /* ensure that it also has .svgicon */
display: inline-block; display: inline-block;
margin-left: var(--spacing-extra-small); margin-left: var(--spacing-extra-small);
font-size: var(--font-size-7);
} }
.avatar { .avatar {
width: 1.8rem; width: 1.8rem;
height: 1.8rem;
} }
.header-nav { .header-nav {
@ -150,6 +152,9 @@ header {
z-index: 100; z-index: 100;
min-width: 8rem; min-width: 8rem;
background-color: var(--card-background); background-color: var(--card-background);
border-style: solid;
border-width: 1px;
border-top-width: 0;
>a { >a {
padding: var(--spacing-small) var(--spacing-medium); padding: var(--spacing-small) var(--spacing-medium);

View File

@ -1,9 +1,21 @@
.avatar { .avatar {
object-fit: cover; object-fit: cover;
border-radius: 100%;
overflow: hidden; overflow: hidden;
background-color: var(--dimmest-color); background-color: var(--dimmest-color);
flex-shrink: 0; flex-shrink: 0;
border: none;
width: var(--avatar-size-normal);
height: var(--avatar-size-normal);
}
.avatar.avatar-user {
border-radius: 999px;
}
.avatar.avatar-small {
width: var(--avatar-size-small);
height: var(--avatar-size-small);
} }
.timeline-item { .timeline-item {

View File

@ -56,8 +56,11 @@ $breakpoint-large: screen and (min-width: 60em)
--spoiler-border: #aaa; --spoiler-border: #aaa;
--site-width: 80rem; --site-width: 54rem;
--site-width-narrow: 60rem; --site-width-narrow: 40rem;
--avatar-size-small: 1.5rem;
--avatar-size-normal: 2.5rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -112,4 +115,4 @@ $breakpoint-large: screen and (min-width: 60em)
--spoiler-border: #777; --spoiler-border: #777;
} }
} }

View File

@ -1 +1,3 @@
<svg viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M6,6l0,-6l2,0l0,6l6,0l0,2l-6,0l0,6l-2,0l0,-6l-6,0l0,-2l6,0Z"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M6,6L6,0L8,0L8,6L14,6L14,8L8,8L8,14L6,14L6,8L0,8L0,6L6,6Z" style="fill-rule:nonzero;"/>
</svg>

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 393 B

View File

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg width="10" height="10" viewBox="0 0 10 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <g transform="matrix(3.30372e-18,0.0539539,-0.0539539,3.30372e-18,9.99997,4.04654e-05)">
<svg width="100%" height="100%" viewBox="0 0 186 186" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(6.12323e-17,1,-1,6.12323e-17,185.342,0.00025)">
<path d="M51.707,185.343C48.966,185.343 46.214,184.299 44.114,182.194C39.92,178 39.92,171.213 44.114,167.019L118.466,92.672L44.114,18.32C39.92,14.126 39.92,7.333 44.114,3.145C48.308,-1.049 55.101,-1.049 59.294,3.145L141.228,85.079C145.422,89.273 145.422,96.066 141.228,100.254L59.294,182.193C57.201,184.293 54.454,185.343 51.707,185.343Z" style="fill-rule:nonzero;"/> <path d="M51.707,185.343C48.966,185.343 46.214,184.299 44.114,182.194C39.92,178 39.92,171.213 44.114,167.019L118.466,92.672L44.114,18.32C39.92,14.126 39.92,7.333 44.114,3.145C48.308,-1.049 55.101,-1.049 59.294,3.145L141.228,85.079C145.422,89.273 145.422,96.066 141.228,100.254L59.294,182.193C57.201,184.293 54.454,185.343 51.707,185.343Z" style="fill-rule:nonzero;"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 910 B

After

Width:  |  Height:  |  Size: 770 B

View File

@ -74,7 +74,6 @@ func ProjectLogoUrl(p *models.Project, lightAsset *models.Asset, darkAsset *mode
func ProjectToTemplate( func ProjectToTemplate(
p *models.Project, p *models.Project,
url string,
) Project { ) Project {
return Project{ return Project{
ID: p.ID, ID: p.ID,
@ -82,7 +81,7 @@ func ProjectToTemplate(
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
Color1: p.Color1, Color1: p.Color1,
Color2: p.Color2, Color2: p.Color2,
Url: url, Url: hmndata.UrlContextForProject(p).BuildHomepage(),
Blurb: p.Blurb, Blurb: p.Blurb,
ParsedDescription: template.HTML(p.ParsedDescription), ParsedDescription: template.HTML(p.ParsedDescription),
@ -98,8 +97,8 @@ func ProjectToTemplate(
} }
} }
func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string) Project { func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff) Project {
res := ProjectToTemplate(&p.Project, url) res := ProjectToTemplate(&p.Project)
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset) res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset)
for _, o := range p.Owners { for _, o := range p.Owners {
res.Owners = append(res.Owners, UserToTemplate(o)) res.Owners = append(res.Owners, UserToTemplate(o))

View File

@ -1,4 +1,4 @@
<header id="site-header" class="bb flex flex-row items-center link--normal"> <header id="site-header" class="flex flex-row items-center link--normal">
<a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo flex-shrink-0"> <a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo flex-shrink-0">
Handmade Handmade
</a> </a>
@ -8,7 +8,7 @@
<a href="{{ .Header.JamsUrl }}">Jams</a> <a href="{{ .Header.JamsUrl }}">Jams</a>
<div class="root-item"> <div class="root-item">
<a aria-expanded="false" aria-controls="events-submenu" class="menu-dropdown-js" href="#"> <a aria-expanded="false" aria-controls="events-submenu" class="menu-dropdown-js" href="#">
Events <div class="menu-chevron svgicon">{{ svg "chevron-down-thick" }}</div> Events <div class="menu-chevron svgicon-lite">{{ svg "chevron-down" }}</div>
</a> </a>
<div class="submenu" id="events-submenu"> <div class="submenu" id="events-submenu">
<a href="{{ .Header.JamsUrl }}">Jams</a> <a href="{{ .Header.JamsUrl }}">Jams</a>
@ -17,7 +17,7 @@
</div> </div>
<div class="root-item"> <div class="root-item">
<a aria-expanded="false" aria-controls="resource-submenu" class="menu-dropdown-js" href="#"> <a aria-expanded="false" aria-controls="resource-submenu" class="menu-dropdown-js" href="#">
Resources <div class="menu-chevron svgicon">{{ svg "chevron-down-thick" }}</div> Resources <div class="menu-chevron svgicon-lite">{{ svg "chevron-down" }}</div>
</a> </a>
<div class="submenu" id="resource-submenu"> <div class="submenu" id="resource-submenu">
<a href="{{ .Header.ForumsUrl }}">Forums</a> <a href="{{ .Header.ForumsUrl }}">Forums</a>
@ -31,7 +31,7 @@
</div> </div>
<div class="root-item"> <div class="root-item">
<a aria-expanded="false" aria-controls="about-submenu" class="menu-dropdown-js" href="#"> <a aria-expanded="false" aria-controls="about-submenu" class="menu-dropdown-js" href="#">
About <div class="menu-chevron svgicon">{{ svg "chevron-down-thick" }}</div> About <div class="menu-chevron svgicon-lite">{{ svg "chevron-down" }}</div>
</a> </a>
<div class="submenu" id="about-submenu"> <div class="submenu" id="about-submenu">
<a href="{{ .Header.ManifestoUrl }}">Manifesto</a> <a href="{{ .Header.ManifestoUrl }}">Manifesto</a>
@ -40,7 +40,7 @@
</div> </div>
</div> </div>
<a class="db ph3 pv2 flex" href="{{ or .Header.UserProfileUrl .LoginPageUrl }}"> <a class="db ph3 pv2 flex" href="{{ or .Header.UserProfileUrl .LoginPageUrl }}">
<img class="avatar" src="{{ with .User }}{{ .AvatarUrl }}{{ end }}"> <img class="avatar avatar-user" src="{{ with .User }}{{ .AvatarUrl }}{{ end }}">
</a> </a>
</header> </header>
<script type="text/javascript"> <script type="text/javascript">

View File

@ -3,7 +3,7 @@
<div class="flex items-center"> <div class="flex items-center">
{{ if .OwnerAvatarUrl }} {{ if .OwnerAvatarUrl }}
<a class="flex flex-shrink-0" href="{{ .OwnerUrl }}"> <a class="flex flex-shrink-0" href="{{ .OwnerUrl }}">
<img class="avatar {{ if not .SmallInfo }}big{{ end }} {{ if .SmallInfo }}mr2{{ else }}mr3{{ end }}" src="{{ .OwnerAvatarUrl }}" /> <img class="avatar avatar-user {{ if not .SmallInfo }}big{{ end }} {{ if .SmallInfo }}mr2{{ else }}mr3{{ end }}" src="{{ .OwnerAvatarUrl }}" />
</a> </a>
{{ end }} {{ end }}

View File

@ -84,29 +84,43 @@
<div class="flex justify-center pa3"> <div class="flex justify-center pa3">
<div class="w-100 mw-site flex g3"> <div class="w-100 mw-site flex g3">
<!-- Sidebar --> <!-- Sidebar -->
<div class="w5 flex flex-column g2"> <div class="w5 flex flex-column g2 flex-shrink-0">
<div class="bg--card pa2"> {{ if .User }}
<div class="pb2 flex justify-between items-center"> <div class="bg--card link--normal">
<span class="f7">Your projects</span> <div class="pa2 flex justify-between items-center">
<span class="svgicon f8">{{ svg "chevron-down" }}</span> <span class="f7">Your projects</span>
<span class="svgicon-lite">{{ svg "chevron-down" }}</span>
</div>
<div class="ph2 flex flex-column g2">
{{ range .UserProjects }}
{{ template "list-project" . }}
{{ else }}
<div class="f7 pv3 tc c--dim">You have not created any projects.</div>
{{ end }}
</div>
<a class="bt mt2 pa2 flex justify-between" href="{{ .NewProjectUrl }}">
<div>Create new project</div>
<div class="svgicon-lite flex items-center">{{ svg "add" }}</div>
</a>
</div> </div>
<div> <div class="bg--card link--normal">
You have not created any projects. <div class="pa2 flex justify-between items-center">
<span class="f7">Following</span>
<span class="svgicon f8">{{ svg "chevron-down" }}</span>
</div>
<div class="ph2 pb2 flex flex-column g2">
{{ range .Following }}
{{ template "list-follow" . }}
{{ else }}
<div class="f7 pv3 tc c--dim">You are not following anything.</div>
{{ end }}
</div>
</div> </div>
</div> {{ end }}
<div class="bg--card pa2">
<div class="pb2 flex justify-between items-center">
<span class="f7">Following</span>
<span class="svgicon f8">{{ svg "chevron-down" }}</span>
</div>
<div>
You are not following anything yet.
</div>
</div>
</div> </div>
<!-- Feed --> <!-- Feed -->
<div class="flex flex-column flex-grow-1"> <div class="flex flex-column flex-grow-1 overflow-hidden">
<div class="timeline flex flex-column g3"> <div class="timeline flex flex-column g3">
{{ range .RecentItems }} {{ range .RecentItems }}
{{ template "timeline_item.html" . }} {{ template "timeline_item.html" . }}
@ -118,3 +132,38 @@
</div> </div>
{{ end }} {{ end }}
{{ define "list-project" }}
<a class="flex g2 items-center" href="{{ .Url }}">
{{ with .Logo }}
<img class="avatar avatar-small" src="{{ . }}">
{{ else }}
<div class="avatar avatar-small"></div>
{{ end }}
<div>{{ .Name }}</div>
</a>
{{ end }}
{{ define "list-follow" }}
{{ if .User }}
<a class="flex g2 items-center" href="{{ .User.ProfileUrl }}">
{{ with .User.AvatarUrl }}
<img class="avatar avatar-user avatar-small" src="{{ . }}">
{{ else }}
<div class="avatar avatar-user avatar-small"></div>
{{ end }}
<div>{{ .User.Name }}</div>
</a>
{{ else if .Project }}
<a class="flex g2 items-center" href="{{ .Project.Url }}">
{{ with .Project.Logo }}
<img class="avatar avatar-small" src="{{ . }}">
{{ else }}
<div class="avatar avatar-small"></div>
{{ end }}
<div>{{ .Project.Name }}</div>
</a>
{{ else }}
???
{{ end }}
{{ end }}

View File

@ -177,6 +177,11 @@ type Asset struct {
Width, Height int Width, Height int
} }
type Follow struct {
User *User
Project *Project
}
type ProjectJamParticipation struct { type ProjectJamParticipation struct {
JamName string JamName string
JamSlug string JamSlug string

View File

@ -236,7 +236,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
userData.Date = p.ProjectAndStuff.Project.DateCreated userData.Date = p.ProjectAndStuff.Project.DateCreated
} }
userData.ProjectsWithLinks = append(userData.ProjectsWithLinks, projectWithLinks{ userData.ProjectsWithLinks = append(userData.ProjectsWithLinks, projectWithLinks{
Project: templates.ProjectAndStuffToTemplate(p.ProjectAndStuff, hmndata.UrlContextForProject(&p.ProjectAndStuff.Project).BuildHomepage()), Project: templates.ProjectAndStuffToTemplate(p.ProjectAndStuff),
Links: projectLinks, Links: projectLinks,
}) })
} }

View File

@ -54,7 +54,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
DiscordInviteUrl: "https://discord.gg/hmn", DiscordInviteUrl: "https://discord.gg/hmn",
NewsletterSignupUrl: hmnurl.BuildAPINewsletterSignup(), NewsletterSignupUrl: hmnurl.BuildAPINewsletterSignup(),
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage()), Project: templates.ProjectToTemplate(&project),
User: templateUser, User: templateUser,
Session: templateSession, Session: templateSession,
Notices: notices, Notices: notices,

View File

@ -165,7 +165,7 @@ func AtomFeed(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects"))
} }
for _, p := range projectsAndStuff { for _, p := range projectsAndStuff {
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectToTemplate(&p.Project)
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN() templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
for _, owner := range p.Owners { for _, owner := range p.Owners {
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner)) templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner))

View File

@ -250,9 +250,7 @@ func getLJ2024FeedData(c *RequestContext, maxTimelineItems int) (JamFeedDataLJ20
projects := make([]templates.Project, 0, len(jamProjects)) projects := make([]templates.Project, 0, len(jamProjects))
for _, jp := range jamProjects { for _, jp := range jamProjects {
urlContext := hmndata.UrlContextForProject(&jp.Project) projects = append(projects, templates.ProjectAndStuffToTemplate(&jp))
projectUrl := urlContext.BuildHomepage()
projects = append(projects, templates.ProjectAndStuffToTemplate(&jp, projectUrl))
} }
projectIds := make([]int, 0, len(jamProjects)) projectIds := make([]int, 0, len(jamProjects))
@ -367,7 +365,7 @@ func JamIndex2023(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
projectIds := make([]int, 0, len(jamProjects)) projectIds := make([]int, 0, len(jamProjects))
@ -460,7 +458,7 @@ func JamFeed2023(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
type JamFeedData struct { type JamFeedData struct {
@ -553,7 +551,7 @@ func JamIndex2023_Visibility(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
projectIds := make([]int, 0, len(jamProjects)) projectIds := make([]int, 0, len(jamProjects))
@ -628,7 +626,7 @@ func JamFeed2023_Visibility(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
type JamFeedData struct { type JamFeedData struct {
@ -767,7 +765,7 @@ func JamIndex2022(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
projectIds := make([]int, 0, len(jamProjects)) projectIds := make([]int, 0, len(jamProjects))
@ -841,7 +839,7 @@ func JamFeed2022(c *RequestContext) ResponseData {
pageProjects := make([]templates.Project, 0, len(jamProjects)) pageProjects := make([]templates.Project, 0, len(jamProjects))
for _, p := range jamProjects { for _, p := range jamProjects {
pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) pageProjects = append(pageProjects, templates.ProjectAndStuffToTemplate(&p))
} }
type JamFeedData struct { type JamFeedData struct {

View File

@ -17,6 +17,31 @@ func Index(c *RequestContext) ResponseData {
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
type LandingTemplateData struct {
templates.BaseData
NewsPost *templates.TimelineItem
FollowingItems []templates.TimelineItem
FeaturedItems []templates.TimelineItem
RecentItems []templates.TimelineItem
NewsItems []templates.TimelineItem
UserProjects []templates.Project
Following []templates.Follow
ManifestoUrl string
PodcastUrl string
AtomFeedUrl string
MarkAllReadUrl string
NewProjectUrl string
JamUrl string
JamDaysUntilStart, JamDaysUntilEnd int
HMSDaysUntilStart, HMSDaysUntilEnd int
HMBostonDaysUntilStart, HMBostonDaysUntilEnd int
}
var err error var err error
var followingItems []templates.TimelineItem var followingItems []templates.TimelineItem
var featuredItems []templates.TimelineItem var featuredItems []templates.TimelineItem
@ -59,25 +84,26 @@ func Index(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
type LandingTemplateData struct { var projects []templates.Project
templates.BaseData if c.CurrentUser != nil {
projectsDb, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
})
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to fetch user projects")
}
NewsPost *templates.TimelineItem for _, p := range projectsDb {
FollowingItems []templates.TimelineItem projects = append(projects, templates.ProjectAndStuffToTemplate(&p))
FeaturedItems []templates.TimelineItem }
RecentItems []templates.TimelineItem }
NewsItems []templates.TimelineItem
ManifestoUrl string var follows []templates.Follow
PodcastUrl string if c.CurrentUser != nil {
AtomFeedUrl string follows, err = FetchFollows(c, c.Conn, c.CurrentUser, c.CurrentUser.ID)
MarkAllReadUrl string if err != nil {
c.Logger.Warn().Err(err).Msg("failed to fetch user follows")
JamUrl string }
JamDaysUntilStart, JamDaysUntilEnd int
HMSDaysUntilStart, HMSDaysUntilEnd int
HMBostonDaysUntilStart, HMBostonDaysUntilEnd int
} }
baseData := getBaseData(c, "", nil) baseData := getBaseData(c, "", nil)
@ -96,10 +122,14 @@ func Index(c *RequestContext) ResponseData {
RecentItems: recentItems, RecentItems: recentItems,
NewsItems: newsItems, NewsItems: newsItems,
UserProjects: projects,
Following: follows,
ManifestoUrl: hmnurl.BuildManifesto(), ManifestoUrl: hmnurl.BuildManifesto(),
PodcastUrl: hmnurl.BuildPodcast(), PodcastUrl: hmnurl.BuildPodcast(),
AtomFeedUrl: hmnurl.BuildAtomFeed(), AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0), MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
NewProjectUrl: hmnurl.BuildProjectNew(),
JamUrl: hmnurl.BuildJamIndex2024_Learning(), JamUrl: hmnurl.BuildJamIndex2024_Learning(),
JamDaysUntilStart: daysUntil(hmndata.LJ2024.StartTime), JamDaysUntilStart: daysUntil(hmndata.LJ2024.StartTime),

View File

@ -238,7 +238,7 @@ func getShuffledOfficialProjects(c *RequestContext) ([]templates.Project, error)
var restProjects []templates.Project var restProjects []templates.Project
now := time.Now() now := time.Now()
for _, p := range official { for _, p := range official {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p)
if p.Project.Slug == "hero" { if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
@ -297,7 +297,7 @@ func getPersonalProjects(c *RequestContext, jamSlug string) ([]templates.Project
var personalProjects []templates.Project var personalProjects []templates.Project
for _, p := range projects { for _, p := range projects {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p)
personalProjects = append(personalProjects, templateProject) personalProjects = append(personalProjects, templateProject)
} }
@ -410,7 +410,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
} }
templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage()) templateData.Project = templates.ProjectAndStuffToTemplate(&p)
for _, owner := range owners { for _, owner := range owners {
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner)) templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner))
} }
@ -486,12 +486,12 @@ func ProjectHomepage(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
} }
templateProjects := make([]templates.Project, 0, len(userProjects)) templateProjects := make([]templates.Project, 0, len(userProjects))
templateProjects = append(templateProjects, templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage())) templateProjects = append(templateProjects, templates.ProjectAndStuffToTemplate(&p))
for _, p := range userProjects { for _, p := range userProjects {
if p.Project.ID == c.CurrentProject.ID { if p.Project.ID == c.CurrentProject.ID {
continue continue
} }
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p)
templateProjects = append(templateProjects, templateProject) templateProjects = append(templateProjects, templateProject)
} }
templateData.SnippetEdit = templates.SnippetEdit{ templateData.SnippetEdit = templates.SnippetEdit{

View File

@ -114,7 +114,7 @@ func Snippet(c *RequestContext) ResponseData {
} }
templateProjects := make([]templates.Project, 0, len(userProjects)) templateProjects := make([]templates.Project, 0, len(userProjects))
for _, p := range userProjects { for _, p := range userProjects {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p)
templateProjects = append(templateProjects, templateProject) templateProjects = append(templateProjects, templateProject)
} }
snippetEdit = templates.SnippetEdit{ snippetEdit = templates.SnippetEdit{

View File

@ -18,18 +18,14 @@ import (
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf" "git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
) )
func FetchFollowTimelineForUser(ctx context.Context, conn db.ConnOrTx, user *models.User) ([]templates.TimelineItem, error) { func FetchFollowTimelineForUser(ctx context.Context, conn db.ConnOrTx, user *models.User) ([]templates.TimelineItem, error) {
perf := perf.ExtractPerf(ctx) perf := perf.ExtractPerf(ctx)
type Follower struct {
UserID int `db:"user_id"`
FollowingUserID *int `db:"following_user_id"`
FollowingProjectID *int `db:"following_project_id"`
}
perf.StartBlock("FOLLOW", "Assemble follow data") perf.StartBlock("FOLLOW", "Assemble follow data")
following, err := db.Query[Follower](ctx, conn, ` following, err := db.Query[models.Follow](ctx, conn, `
SELECT $columns SELECT $columns
FROM follower FROM follower
WHERE user_id = $1 WHERE user_id = $1
@ -68,86 +64,58 @@ type TimelineQuery struct {
func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, q TimelineQuery) ([]templates.TimelineItem, error) { func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, q TimelineQuery) ([]templates.TimelineItem, error) {
perf := perf.ExtractPerf(ctx) perf := perf.ExtractPerf(ctx)
var users []*models.User // var users []*models.User
var projects []hmndata.ProjectAndStuff // var projects []hmndata.ProjectAndStuff
var snippets []hmndata.SnippetAndStuff var snippets []hmndata.SnippetAndStuff
var posts []hmndata.PostAndStuff var posts []hmndata.PostAndStuff
var streamers []hmndata.TwitchStreamer // var streamers []hmndata.TwitchStreamer
var streams []*models.TwitchStreamHistory // var streams []*models.TwitchStreamHistory
var err error
perf.StartBlock("TIMELINE", "Fetch timeline data") perf.StartBlock("TIMELINE", "Fetch timeline data")
if len(q.UserIDs) > 0 || len(q.ProjectIDs) > 0 { {
var err error
if len(q.UserIDs) > 0 {
users, err = hmndata.FetchUsers(ctx, conn, currentUser, hmndata.UsersQuery{
UserIDs: q.UserIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch users")
}
}
// NOTE(asaf): Clear out invalid users in case we banned someone after they got followed
validUserIDs := make([]int, 0, len(q.UserIDs))
for _, u := range users {
validUserIDs = append(validUserIDs, u.ID)
}
if len(q.ProjectIDs) > 0 {
projects, err = hmndata.FetchProjects(ctx, conn, currentUser, hmndata.ProjectsQuery{
ProjectIDs: q.ProjectIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch projects")
}
}
// NOTE(asaf): The original projectIDs might contain hidden/abandoned projects,
// so we recreate it after the projects get filtered by FetchProjects.
validProjectIDs := make([]int, 0, len(q.ProjectIDs))
for _, p := range projects {
validProjectIDs = append(validProjectIDs, p.Project.ID)
}
snippets, err = hmndata.FetchSnippets(ctx, conn, currentUser, hmndata.SnippetQuery{ snippets, err = hmndata.FetchSnippets(ctx, conn, currentUser, hmndata.SnippetQuery{
OwnerIDs: validUserIDs, OwnerIDs: q.UserIDs,
ProjectIDs: validProjectIDs, ProjectIDs: q.ProjectIDs,
Limit: q.Limit,
}) })
if err != nil { if err != nil {
return nil, oops.New(err, "failed to fetch user snippets") return nil, oops.New(err, "failed to fetch timeline snippets")
} }
posts, err = hmndata.FetchPosts(ctx, conn, currentUser, hmndata.PostsQuery{ posts, err = hmndata.FetchPosts(ctx, conn, currentUser, hmndata.PostsQuery{
UserIDs: validUserIDs, UserIDs: q.UserIDs,
ProjectIDs: validProjectIDs, ProjectIDs: q.ProjectIDs,
SortDescending: true, SortDescending: true,
Limit: q.Limit,
}) })
if err != nil { if err != nil {
return nil, oops.New(err, "failed to fetch user posts") return nil, oops.New(err, "failed to fetch timeline posts")
} }
streamers, err = hmndata.FetchTwitchStreamers(ctx, conn, hmndata.TwitchStreamersQuery{ // streamers, err = hmndata.FetchTwitchStreamers(ctx, conn, hmndata.TwitchStreamersQuery{
UserIDs: validUserIDs, // UserIDs: validUserIDs,
ProjectIDs: validProjectIDs, // ProjectIDs: validProjectIDs,
}) // })
if err != nil { // if err != nil {
return nil, oops.New(err, "failed to fetch streamers") // return nil, oops.New(err, "failed to fetch streamers")
} // }
twitchLogins := make([]string, 0, len(streamers)) // twitchLogins := make([]string, 0, len(streamers))
for _, s := range streamers { // for _, s := range streamers {
twitchLogins = append(twitchLogins, s.TwitchLogin) // twitchLogins = append(twitchLogins, s.TwitchLogin)
} // }
streams, err = db.Query[models.TwitchStreamHistory](ctx, conn, // streams, err = db.Query[models.TwitchStreamHistory](ctx, conn,
` // `
SELECT $columns FROM twitch_stream_history WHERE twitch_login = ANY ($1) // SELECT $columns FROM twitch_stream_history WHERE twitch_login = ANY ($1)
`, // `,
twitchLogins, // twitchLogins,
) // )
if err != nil { // if err != nil {
return nil, oops.New(err, "failed to fetch stream histories") // return nil, oops.New(err, "failed to fetch stream histories")
} // }
} }
perf.EndBlock() perf.EndBlock()
@ -184,38 +152,38 @@ func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.Us
timelineItems = append(timelineItems, item) timelineItems = append(timelineItems, item)
} }
for _, s := range streams { // for _, s := range streams {
ownerAvatarUrl := "" // ownerAvatarUrl := ""
ownerName := "" // ownerName := ""
ownerUrl := "" // ownerUrl := ""
for _, streamer := range streamers { // for _, streamer := range streamers {
if streamer.TwitchLogin == s.TwitchLogin { // if streamer.TwitchLogin == s.TwitchLogin {
if streamer.UserID != nil { // if streamer.UserID != nil {
for _, u := range users { // for _, u := range users {
if u.ID == *streamer.UserID { // if u.ID == *streamer.UserID {
ownerAvatarUrl = templates.UserAvatarUrl(u) // ownerAvatarUrl = templates.UserAvatarUrl(u)
ownerName = u.BestName() // ownerName = u.BestName()
ownerUrl = hmnurl.BuildUserProfile(u.Username) // ownerUrl = hmnurl.BuildUserProfile(u.Username)
break // break
} // }
} // }
} else if streamer.ProjectID != nil { // } else if streamer.ProjectID != nil {
for _, p := range projects { // for _, p := range projects {
if p.Project.ID == *streamer.ProjectID { // if p.Project.ID == *streamer.ProjectID {
ownerAvatarUrl = templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset) // ownerAvatarUrl = templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset)
ownerName = p.Project.Name // ownerName = p.Project.Name
ownerUrl = hmndata.UrlContextForProject(&p.Project).BuildHomepage() // ownerUrl = hmndata.UrlContextForProject(&p.Project).BuildHomepage()
} // }
break // break
} // }
} // }
break // break
} // }
} // }
item := TwitchStreamToTimelineItem(s, ownerAvatarUrl, ownerName, ownerUrl) // item := TwitchStreamToTimelineItem(s, ownerAvatarUrl, ownerName, ownerUrl)
timelineItems = append(timelineItems, item) // timelineItems = append(timelineItems, item)
} // }
perf.StartBlock("TIMELINE", "Sort timeline") perf.StartBlock("TIMELINE", "Sort timeline")
sort.Slice(timelineItems, func(i, j int) bool { sort.Slice(timelineItems, func(i, j int) bool {
@ -224,9 +192,85 @@ func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.Us
perf.EndBlock() perf.EndBlock()
perf.EndBlock() perf.EndBlock()
if q.Limit > 0 {
timelineItems = utils.ClampSlice(timelineItems, q.Limit)
}
return timelineItems, nil return timelineItems, nil
} }
func FetchFollows(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, userID int) ([]templates.Follow, error) {
perf := perf.ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch follows")
following, err := db.Query[models.Follow](ctx, conn, `
SELECT $columns
FROM follower
WHERE user_id = $1
`, userID)
if err != nil {
return nil, oops.New(err, "failed to fetch follows")
}
perf.EndBlock()
var userIDs, projectIDs []int
for _, follow := range following {
if follow.FollowingUserID != nil {
userIDs = append(userIDs, *follow.FollowingUserID)
}
if follow.FollowingProjectID != nil {
projectIDs = append(projectIDs, *follow.FollowingProjectID)
}
}
var users []*models.User
var projectsAndStuff []hmndata.ProjectAndStuff
if len(userIDs) > 0 {
users, err = hmndata.FetchUsers(ctx, conn, currentUser, hmndata.UsersQuery{
UserIDs: userIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch users for follows")
}
}
if len(projectIDs) > 0 {
projectsAndStuff, err = hmndata.FetchProjects(ctx, conn, currentUser, hmndata.ProjectsQuery{
ProjectIDs: projectIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch projects for follows")
}
}
var result []templates.Follow
for _, follow := range following {
if follow.FollowingUserID != nil {
for _, user := range users {
if user.ID == *follow.FollowingUserID {
u := templates.UserToTemplate(user)
result = append(result, templates.Follow{
User: &u,
})
break
}
}
}
if follow.FollowingProjectID != nil {
for _, p := range projectsAndStuff {
if p.Project.ID == *follow.FollowingProjectID {
proj := templates.ProjectAndStuffToTemplate(&p)
result = append(result, templates.Follow{
Project: &proj,
})
break
}
}
}
}
return result, nil
}
type TimelineTypeTitles struct { type TimelineTypeTitles struct {
TypeTitleFirst string TypeTitleFirst string
TypeTitleNotFirst string TypeTitleNotFirst string
@ -369,7 +413,7 @@ func SnippetToTimelineItem(
return projects[i].Project.Name < projects[j].Project.Name return projects[i].Project.Name < projects[j].Project.Name
}) })
for _, proj := range projects { for _, proj := range projects {
item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj, hmndata.UrlContextForProject(&proj.Project).BuildHomepage())) item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj))
} }
return item return item

View File

@ -106,7 +106,7 @@ func UserProfile(c *RequestContext) ResponseData {
templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
numPersonalProjects := 0 numPersonalProjects := 0
for _, p := range projectsAndStuff { for _, p := range projectsAndStuff {
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p)
templateProjects = append(templateProjects, templateProject) templateProjects = append(templateProjects, templateProject)
if p.Project.Personal { if p.Project.Personal {