1
0
Fork 0
mirror of https://git.sr.ht/~roxwize/mipilin synced 2025-03-03 17:42:06 +00:00

Today is Journal Day

Signed-off-by: roxwize <rae@roxwize.xyz>
This commit is contained in:
Rae 5e 2025-03-01 13:17:13 -05:00
parent f7e44cddca
commit 927193c102
Signed by: rae
GPG key ID: 5B1A0FAB9BAB81EE
14 changed files with 742 additions and 55 deletions

View file

@ -35,7 +35,8 @@ export const journalEntries = pgTable("journal_entries", {
title: varchar({ length: 64 }).default("").notNull(),
entry: varchar({ length: 4096 }).default("").notNull(),
visibility: integer().default(1).notNull(),
date: timestamp().notNull()
date: timestamp().notNull(),
updated: timestamp()
});
export const journalComments = pgTable("journal_comments", {

View file

@ -0,0 +1 @@
ALTER TABLE "journal_entries" ADD COLUMN "updated" timestamp;

View file

@ -0,0 +1,502 @@
{
"id": "5131d53f-2980-420b-889d-999a3ab9fa3e",
"prevId": "0c1bcfed-899c-4ec9-9a7d-c987aa50dfb8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.follows": {
"name": "follows",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"follower_id": {
"name": "follower_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"follows_user_id_users_id_fk": {
"name": "follows_user_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"follows_follower_id_users_id_fk": {
"name": "follows_follower_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"follower_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"follows_user_id_follower_id_pk": {
"name": "follows_user_id_follower_id_pk",
"columns": [
"user_id",
"follower_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invite_codes": {
"name": "invite_codes",
"schema": "",
"columns": {
"token": {
"name": "token",
"type": "varchar(22)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"granted": {
"name": "granted",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "'1970-01-01 00:00:00.000'"
},
"confers": {
"name": "confers",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"invite_codes_user_id_users_id_fk": {
"name": "invite_codes_user_id_users_id_fk",
"tableFrom": "invite_codes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.journal_comments": {
"name": "journal_comments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "journal_comments_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user": {
"name": "user",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"entry": {
"name": "entry",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"journal_comments_user_users_id_fk": {
"name": "journal_comments_user_users_id_fk",
"tableFrom": "journal_comments",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"journal_comments_entry_journal_entries_id_fk": {
"name": "journal_comments_entry_journal_entries_id_fk",
"tableFrom": "journal_comments",
"tableTo": "journal_entries",
"columnsFrom": [
"entry"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.journal_entries": {
"name": "journal_entries",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "journal_entries_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user": {
"name": "user",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"mood-change": {
"name": "mood-change",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"entry": {
"name": "entry",
"type": "varchar(4096)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"visibility": {
"name": "visibility",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"date": {
"name": "date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated": {
"name": "updated",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"journal_entries_user_users_id_fk": {
"name": "journal_entries_user_users_id_fk",
"tableFrom": "journal_entries",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.profiles": {
"name": "profiles",
"schema": "",
"columns": {
"user": {
"name": "user",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"bio": {
"name": "bio",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"website": {
"name": "website",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"profiles_user_users_id_fk": {
"name": "profiles_user_users_id_fk",
"tableFrom": "profiles",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.updates": {
"name": "updates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "updates_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user": {
"name": "user",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"description": {
"name": "description",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"date": {
"name": "date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"updates_user_users_id_fk": {
"name": "updates_user_users_id_fk",
"tableFrom": "updates",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(26)",
"primaryKey": false,
"notNull": true
},
"pass": {
"name": "pass",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"registered": {
"name": "registered",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"users_name_unique": {
"name": "users_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -29,6 +29,13 @@
"when": 1737088748932,
"tag": "0003_closed_alex_power",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1740852098874,
"tag": "0004_lyrical_photon",
"breakpoints": true
}
]
}

View file

@ -8,7 +8,7 @@ import {
updates,
users
} from "../db/schema.js";
import { and, count, desc, eq, sql } from "drizzle-orm";
import { and, count, desc, eq, ne, sql } from "drizzle-orm";
import dayjs from "dayjs";
import {
confirm,
@ -83,6 +83,47 @@ export default async function (app: Express, db: NodePgDatabase) {
};
});
// recent journal entries
const recentJournalUpdates = (
await db
.select({
id: journalEntries.id,
user: users.name,
title: journalEntries.title,
date: journalEntries.date,
visibility: journalEntries.visibility
})
.from(journalEntries)
.where(
users.id === req.session["uid"] ||
req.session["status"] & UserStatus.MODERATOR
? eq(journalEntries.user, users.id)
: and(
eq(journalEntries.user, users.id),
ne(journalEntries.visibility, 0)
)
)
.innerJoin(
follows,
and(
eq(follows.userId, journalEntries.user),
eq(follows.followerId, req.session["uid"])
)
)
.leftJoin(users, eq(journalEntries.user, users.id))
.orderBy(desc(journalEntries.date))
.limit(9)
).map((e) => {
return {
id: e.id,
user: e.user,
title: e.title,
date: e.date,
visibility: e.visibility,
relativeDate: now.to(e.date)
};
});
// user invite codes
const codes = (
await db
@ -126,10 +167,13 @@ export default async function (app: Express, db: NodePgDatabase) {
moodsSorted,
moodHistory,
recentUpdates,
recentJournalUpdates,
codes,
codesUsed,
followed,
isTrusted: req.session["status"] & (UserStatus.MODERATOR | UserStatus.TRUSTED),
isTrusted:
req.session["status"] &
(UserStatus.MODERATOR | UserStatus.TRUSTED),
feed: []
});
});
@ -140,9 +184,19 @@ export default async function (app: Express, db: NodePgDatabase) {
}
// make sure the user isnt updating too fast
//! TODO: also do this for journal entries
const lastUpdate = (await db.select({ date: updates.date }).from(updates).where(eq(updates.user, req.session["uid"])).orderBy(desc(updates.date)).limit(1))?.[0];
const lastUpdate = (
await db
.select({ date: updates.date })
.from(updates)
.where(eq(updates.user, req.session["uid"]))
.orderBy(desc(updates.date))
.limit(1)
)?.[0];
if (Date.now() < lastUpdate?.date?.getTime() + 10 * 1000) {
req.flash("error", "You're updating your mood too fast! Wait ten seconds between updates.");
req.flash(
"error",
"You're updating your mood too fast! Wait ten seconds between updates."
);
res.redirect(req.get("Referrer") || "/");
return;
}
@ -183,8 +237,18 @@ export default async function (app: Express, db: NodePgDatabase) {
return;
}
const { user } = (await db.select({user: updates.user}).from(updates).where(eq(updates.id, u)).limit(1))[0];
if (!user || (user !== req.session["uid"] && !(req.session["status"] & UserStatus.MODERATOR))) {
const { user } = (
await db
.select({ user: updates.user })
.from(updates)
.where(eq(updates.id, u))
.limit(1)
)[0];
if (
!user ||
(user !== req.session["uid"] &&
!(req.session["status"] & UserStatus.MODERATOR))
) {
render404(db, res, req);
return;
}
@ -222,6 +286,7 @@ export default async function (app: Express, db: NodePgDatabase) {
moodString?: string;
date: Date;
visibility: number;
updated: Date;
} = (
await db
.select({
@ -232,7 +297,8 @@ export default async function (app: Express, db: NodePgDatabase) {
content: journalEntries.entry,
moodChange: journalEntries.moodChange,
date: journalEntries.date,
visibility: journalEntries.visibility
visibility: journalEntries.visibility,
updated: journalEntries.updated
})
.from(journalEntries)
.where(eq(journalEntries.id, id))
@ -263,10 +329,12 @@ export default async function (app: Express, db: NodePgDatabase) {
: entry.content;
const entryTimestamp = dayjs(entry.date).fromNow();
const updatedTimestamp = dayjs(entry.updated).fromNow();
const isSelf = entry.uid === req.session["uid"];
render(db, "journal_view", entry.title, res, req, {
entry,
entryTimestamp,
updatedTimestamp,
isSelf,
isMod
});
@ -282,7 +350,11 @@ export default async function (app: Express, db: NodePgDatabase) {
const entry = (
await db
.select({
uid: journalEntries.user
uid: journalEntries.user,
title: journalEntries.title,
entry: journalEntries.entry,
visibility: journalEntries.visibility,
mood: journalEntries.moodChange
})
.from(journalEntries)
.where(eq(journalEntries.id, id))
@ -308,7 +380,62 @@ export default async function (app: Express, db: NodePgDatabase) {
}
if (req.body.action === "edit") {
// TODO!!
if (
req.body.title ||
req.body.description ||
req.body.moodDelta ||
req.body.visiblity
) {
if (!req.session["loggedIn"]) {
res.redirect("/login");
return;
}
if (req.body.title.length > 64) {
req.flash("error", "Title too long!");
res.redirect("/journal");
return;
}
if (req.body.description.length > 4096) {
req.flash("error", "Entry too long!");
res.redirect("/journal");
return;
}
const moodChange = parseInt(req.body.moodDelta);
const visibility = parseInt(req.body.visibility);
if (isNaN(moodChange) || isNaN(visibility)) {
req.flash(
"error",
"One of the values was improperly specified."
);
res.redirect(`/journal/${req.params.id}`);
return;
}
await db
.update(journalEntries)
.set({
//@ts-expect-error
title: req.body.title,
entry: req.body.description
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\n", "<br>"),
moodChange: req.body.moodDelta,
visibility: req.body.visibility,
updated: new Date(Date.now())
})
.where(eq(journalEntries.id, id));
res.redirect(`/journal/${req.params.id}`);
return;
}
render(db, "journal", `edit ${entry.title}`, res, req, {
entry,
id,
edit: true
});
} else if (req.body.action === "delete") {
if (!confirm(db, res, req, `/journal/${req.params.id}`)) {
return;

View file

@ -76,3 +76,26 @@ input.ovm-input:checked+label > img {
label.ovm-input:last-child > img {
border-right-width: 1px;
}
.feed-journal-entries {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* list-style: none;
padding-inline: 0;
column-count: 3;
margin-top: 0; */
}
.feed-journal-entry {
margin: 4px 0;
padding: 8px;
break-inside: avoid-column;
}
.feed-journal-entry:nth-child(6n+4), .feed-journal-entry:nth-child(6n+5), .feed-journal-entry:nth-child(6n+6) {
background-color: rgba(0, 0, 0, 0.1);
}
.feed-journal-entry:hover {
background-color: rgba(0, 0, 0, 0.2);
}

View file

@ -247,6 +247,10 @@ tr:hover td {
width: 66ch;
}
hr {
border-color: rgba(0, 0, 0, 0.3)
}
@font-face {
font-family: "Gohufont 14";
src: url("/fonts/gohufont-14.ttf") format("truetype"),

View file

@ -17,7 +17,7 @@ mixin feed(feed, hideUser)
button.button-link(title="Delete this update") x
.feed-update-date(title=update.date.toLocaleString())= update.relativeDate
else
span [no updates]
span [no mood updates]
mixin invite_code_expiration(code)
- const timestamp = code.expires.getTime()
@ -27,3 +27,37 @@ mixin invite_code_expiration(code)
td.error EXPIRED
else
td= code.expiresString
mixin journal_entry_suffix(entry)
span.subtle(title=entry.date.toLocaleString())
| (#{entry.relativeDate}
if entry.visibility !== 1
| ,
if entry.visibility === 0
| private
if entry.visibility === 2
| mood-only
| )
mixin ovm(entry)
- const e = entry?.mood
#ovm
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-mb", value="2", required checked=(e === 2))
label.ovm-input(for="moodDelta-mb", title="Much better")
img(src="/img/upup.svg", alt="Much better")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-b", value="1", required checked=(e === 1))
label.ovm-input(for="moodDelta-b", title="Better")
img(src="/img/up.svg", alt="Better")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-nc", value="0", required checked=(!entry || e === 0))
label.ovm-input(for="moodDelta-nc", title="About the same")
img(src="/img/line.svg", alt="About the same")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-w", value="-1", required checked=(e === -1))
label.ovm-input(for="moodDelta-w", title="Worse")
img(src="/img/down.svg", alt="Worse")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-mw", value="-2", required checked=(e === -2))
label.ovm-input(for="moodDelta-mw", title="Much worse")
img(src="/img/downdown.svg", alt="Much worse")

View file

@ -48,6 +48,13 @@ block content
button(type="submit") Update
h1#feed(style="margin-top:0.5em;") Feed
if recentJournalUpdates.length > 0
.feed-journal-entries
for entry of recentJournalUpdates
.feed-journal-entry
a(href=`/journal/${entry.id}`)= entry.title
+journal_entry_suffix(entry, session.user === entry.user)
hr
+feed(recentUpdates)
h1#invite-codes(style="margin-top:1em;") Invite codes

View file

@ -1,52 +1,43 @@
extends site.pug
include _util.pug
block head
link(rel="stylesheet", href="/css/dashboard.css")
script(type="module", src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js", nonce=nonce)
block content
h1 Your Journal
p This is where you can log your overall mood every day, and get a glimpse at how your life is going so far!
p In the near future there will be a magnificient graph that will let you visualize your past entries and your mood trends. You must tay stuned.
form#journal-update(action="/journal/update", method="post")
if edit
h1 Edit #{entry.title}
else
h1 Your Journal
p This is where you can log your overall mood every day, and get a glimpse at how your life is going so far!
p In the near future there will be a magnificient graph that will let you visualize your past entries and your mood trends. You must tay stuned.
form#journal-update(action=edit ? `/journal/${id}/edit` : "/journal/update", method="post")
if edit
input(type="hidden", name="action", value="edit", readonly)
.input
span Overall mood change (how do you feel compared to yesterday?)
#ovm
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-mb", value="2", required)
label.ovm-input(for="moodDelta-mb", title="Much better")
img(src="/img/upup.svg", alt="Much better")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-b", value="1", required)
label.ovm-input(for="moodDelta-b", title="Better")
img(src="/img/up.svg", alt="Better")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-nc", value="0", required checked)
label.ovm-input(for="moodDelta-nc", title="About the same")
img(src="/img/line.svg", alt="About the same")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-w", value="-1", required)
label.ovm-input(for="moodDelta-w", title="Worse")
img(src="/img/down.svg", alt="Worse")
input.ovm-input(type="radio", name="moodDelta", id="moodDelta-mw", value="-2", required)
label.ovm-input(for="moodDelta-mw", title="Much worse")
img(src="/img/downdown.svg", alt="Much worse")
+ovm(entry)
.input
label(for="title") Title
input(type="text", name="title", id="title", placeholder="max 64 chars", maxlength="64")
input(type="text", name="title", id="title", placeholder="max 64 chars", maxlength="64", value=edit ? entry.title : "")
.input
label(for="description") Journal entry for today
textarea(name="description", id="description", placeholder="max 4096 chars", maxlength="4096", cols="60", rows="12")
if edit
| #{entry.entry}
.input
span Visibility
- const v = entry?.visibility ?? 1
div#visibility-control
input(type="radio", name="visibility", id="visibility-public", value="1", checked)
input(type="radio", name="visibility", id="visibility-public", value="1", checked=(v === 1))
label(for="visibility-public") Public
br
input(type="radio", name="visibility", id="visibility-private", value="0")
input(type="radio", name="visibility", id="visibility-private", value="0", checked=(v === 0))
label(for="visibility-private") Private
br
input(type="radio", name="visibility", id="visibility-moodChange-only", value="2")
input(type="radio", name="visibility", id="visibility-moodChange-only", value="2", checked=(v === 2))
label(for="visibility-moodChange-only") Mood only
button(type="submit") Submit
button(type="submit")= edit ? "Edit" : "Submit"

View file

@ -1,5 +0,0 @@
extends site.pug
block content
p God i fucking hate myself
textarea(name="description", id="description", placeholder="max 4096 chars", maxlength="4096", cols="60", rows="12")

View file

@ -14,10 +14,13 @@ block content
a(href=`/users/${entry.uname}`)= entry.uname
|
span(title=entry.date.toLocaleString()) #{entryTimestamp}
if entry.updated
span(title=entry.updated.toLocaleString()) (last updated #{updatedTimestamp})
br
br
div
| Mood:
strong= entry.moodString
br
//- I dont know why i made this unsafe but its probably for some markdown thing i was planning on doing
div!= entry.content

View file

@ -39,7 +39,7 @@ html(lang="en")
| You should log in! It's FUN!!
span#ticker-marquee
marquee
| The beta is still a thing that is happening! If something fucks up plz let me know! &lt;3
| 01 03 25: A word from Rae Mipilin herself: Listen to Patricia Taxxon PLEASE / The beta is still a thing that is happening! If something fucks up plz let me know! &lt;3
#page
block page
#content

View file

@ -35,15 +35,7 @@ block content
for entry of userJournalEntries
li
a(href=`/journal/${entry.id}`)= entry.title
span.subtle(title=entry.date.toLocaleString())
| (#{entry.relativeDate}
if entry.visibility !== 1
| ,
if entry.visibility === 0
| private
if entry.visibility === 2
| mood-only
| )
+journal_entry_suffix(entry)
else
div [no entries]
br