Initial homepage and feed up and running

This commit is contained in:
Garrett Mills 2022-03-29 12:59:31 -05:00
parent 0c01712341
commit 2a8571d6dd
38 changed files with 864 additions and 47 deletions

19
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="www_1@db03.millslan.net" uuid="1002a9b7-c04c-43d0-bbbf-bd2c4fc7b6a9">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://db03.millslan.net:5432/www_1</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="@db03.millslan.net" uuid="afe39e7a-503d-4718-abe7-8bfb0428771b">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://db03.millslan.net:27017</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/exbuild" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="di" />

View File

@ -8,7 +8,7 @@
"lib": "lib"
},
"dependencies": {
"@extollo/lib": "^0.9.0",
"@extollo/lib": "^0.9.1",
"copyfiles": "^2.4.1",
"rimraf": "^3.0.2",
"ts-expose-internals": "^4.5.4",

View File

@ -2,7 +2,7 @@ lockfileVersion: 5.3
specifiers:
'@extollo/cc': ^0.6.0
'@extollo/lib': ^0.9.0
'@extollo/lib': ^0.9.1
copyfiles: ^2.4.1
rimraf: ^3.0.2
ts-expose-internals: ^4.5.4
@ -12,7 +12,7 @@ specifiers:
zod: ^3.11.6
dependencies:
'@extollo/lib': 0.9.0
'@extollo/lib': 0.9.1
copyfiles: 2.4.1
rimraf: 3.0.2
ts-expose-internals: 4.5.4
@ -110,8 +110,8 @@ packages:
- supports-color
dev: true
/@extollo/lib/0.9.0:
resolution: {integrity: sha512-Gu9qwjwjDWPfXrp1ThyjFIROWXD8jXqwz4c7JxjWoWbGnY0dLGAZHv6MOF1tqNGU9zTW399jpGS/7lyazApMpQ==}
/@extollo/lib/0.9.1:
resolution: {integrity: sha512-HPYwlKO0SHZsTQBQh5zTA8oErIEncdFWKr3LGs3LmL7Dq8rWN7dQ1qK2qk809Fc7FBqWeiZKuOsRA8yLkhr6Kg==}
dependencies:
'@atao60/fse-cli': 0.1.7
'@extollo/ui': 0.1.0_@types+node@14.18.12

View File

@ -1,5 +1,5 @@
import { env } from '@extollo/lib'
export default {
name: env('APP_NAME', 'Extollo'),
name: env('APP_NAME', 'Garrett Mills'),
}

View File

@ -3,7 +3,7 @@ import { env } from "@extollo/lib";
export default {
connections: {
default: {
user: env('DATABASE_USERNAME', 'extollo'),
user: env('DATABASE_USERNAME', 'www'),
password: env('DATABASE_PASSWORD'),
host: env('DATABASE_HOST', 'localhost'),
port: env('DATABASE_PORT', 5432),

View File

@ -0,0 +1,59 @@
import {Controller, view, Injectable, SecurityContext, Inject, Collection} from '@extollo/lib'
import {WorkItem} from '../../models/WorkItem.model'
import {FeedPost} from '../../models/FeedPost.model'
@Injectable()
export class Home extends Controller {
@Inject()
protected readonly security!: SecurityContext
public async welcome() {
const workItems = await this.getWorkItems()
const feedPosts = await this.getFeedPosts()
return view('welcome', {
feedPosts: feedPosts.toArray(),
workItemGroups: workItems.groupBy(item => item.startDate.getFullYear()),
workItemYears: workItems.map(item => item.startDate.getFullYear())
.unique()
.toArray(),
workItemHiddenYears: workItems.filter(item => item.endDate)
.map(item => item.startDate.getFullYear())
.unique()
.toArray()
})
}
public async feed() {
const feedPosts = await this.getFeedPosts(true)
return view('feed', {
feedPosts: feedPosts.toArray(),
})
}
protected async getFeedPosts(all = false): Promise<Collection<FeedPost>> {
const query = FeedPost.query<FeedPost>()
.orderByDescending('posted_at')
if ( !all ) {
query.limit(6)
}
if ( !this.security.hasUser() ) {
query.where('visible', '=', true)
}
return query.get().collect()
}
protected async getWorkItems(): Promise<Collection<WorkItem>> {
const query = WorkItem.query<WorkItem>()
.orderByDescending('start_date')
if ( !this.security.hasUser() ) {
query.where('visible', '=', true)
}
return query.get().collect()
}
}

View File

@ -1,26 +0,0 @@
import {Controller, view, Session, Inject, Injectable, Locale, Validator} from '@extollo/lib'
import {UserLogin} from "../../../types/UserLogin";
@Injectable()
export class Home extends Controller {
@Inject()
protected readonly session!: Session;
@Inject()
protected readonly locale!: Locale;
public welcome() {
this.session.set('app_visits', this.session.get('app_visits', 0) + 1)
const valid = new Promise<UserLogin>(() => {})
return view('@extollo:welcome', {
app_visits: this.session.get('app_visits'),
locale: this.locale.helper(),
})
}
public test() {
return new Validator<UserLogin>()
}
}

View File

@ -1,13 +1,17 @@
import {Route, SessionAuthMiddleware, AuthRequiredMiddleware} from '@extollo/lib'
import {Home} from '../controllers/main/Home.controller'
import {Route, SessionAuthMiddleware} from '@extollo/lib'
import {Home} from '../controllers/Home.controller'
Route.group('/', () => {
Route.get('/')
.calls<Home>(Home, home => home.welcome)
})
Route.group('', () => {
Route.get('/dash')
.pre(AuthRequiredMiddleware)
.calls<Home>(Home, home => home.welcome)
Route.get('/feed')
.calls<Home>(Home, home => home.feed)
.alias('feed')
}).pre(SessionAuthMiddleware)
// Route.group('', () => {
// Route.get('/dash')
// .pre(AuthRequiredMiddleware)
// .calls<Home>(Home, home => home.welcome)
// }).pre(SessionAuthMiddleware)

View File

@ -0,0 +1,58 @@
import {DatabaseService, FieldType, Inject, Injectable, Migration} from '@extollo/lib'
/**
* CreateWorkItemsTableMigration
* ----------------------------------
* Create the work_items table to track project history.
*/
@Injectable()
export default class CreateWorkItemsTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
/**
* Apply the migration.
*/
async up(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('work_items')
table.primaryKey('work_item_id').required()
table.column('visible')
.type(FieldType.bool)
.required()
.default(false)
table.column('name')
.type(FieldType.varchar)
.required()
table.column('description')
.type(FieldType.text)
.required()
.default('')
table.column('start_date')
.type(FieldType.timestamp)
.required()
table.column('end_date')
.type(FieldType.timestamp)
.nullable()
await schema.commit(table)
}
/**
* Undo the migration.
*/
async down(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('work_items')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@ -0,0 +1,58 @@
import {DatabaseService, FieldType, Inject, Injectable, Migration, raw} from '@extollo/lib'
/**
* CreateFeedPostsTableMigration
* ----------------------------------
* Create the table to hold posts to the "updates" feed.
*/
@Injectable()
export default class CreateFeedPostsTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
/**
* Apply the migration.
*/
async up(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('feed_posts')
table.primaryKey('feed_post_id', FieldType.varchar)
.required()
// date, tag, text, visible
table.column('posted_at')
.type(FieldType.timestamp)
.default(raw('NOW()'))
.required()
table.column('tag')
.type(FieldType.varchar)
.required()
table.column('visible')
.type(FieldType.bool)
.default(false)
.required()
table.column('body')
.type(FieldType.text)
.default('')
.required()
await schema.commit(table)
}
/**
* Undo the migration.
*/
async down(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('feed_posts')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@ -0,0 +1,21 @@
import {Field, FieldType, Model} from '@extollo/lib'
export class FeedPost extends Model<FeedPost> {
protected static table = 'feed_posts'
protected static key = 'feed_post_id'
@Field(FieldType.varchar, 'feed_post_id')
public readonly feedPostId?: string
@Field(FieldType.timestamp, 'posted_at')
public postedAt: Date = new Date()
@Field(FieldType.varchar)
public tag!: string
@Field(FieldType.bool)
public visible = false
@Field(FieldType.text)
public body = ''
}

View File

@ -0,0 +1,44 @@
import {Field, FieldType, Model} from '@extollo/lib'
export class WorkItem extends Model<WorkItem> {
protected static table = 'work_items'
protected static key = 'work_item_id'
@Field(FieldType.serial, 'work_item_id')
public readonly workItemId!: number
@Field(FieldType.bool)
public visible: boolean = false
@Field(FieldType.varchar)
public name!: string
@Field(FieldType.text)
public description: string = ''
@Field(FieldType.timestamp, 'start_date')
public startDate!: Date
@Field(FieldType.timestamp, 'end_date')
public endDate?: Date
public startDisplay(): string {
return `${this.getMonth(this.startDate.getMonth())} ${this.startDate.getFullYear()}`
}
public endDisplay(): string {
if ( !this.endDate ) {
return 'Present'
}
return `${this.getMonth(this.endDate.getMonth())} ${this.endDate.getFullYear()}`
}
public rangeDisplay(): string {
return `${this.startDisplay()} - ${this.endDisplay()}`
}
protected getMonth(index: number): string {
return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][index]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,374 @@
@font-face {
font-family: "Raj";
src: url("/assets/font/rajdhani/Rajdhani-Regular.ttf");
font-weight: normal;
font-display: swap;
}
@font-face {
font-family: "Raj";
src: url("/assets/font/rajdhani/Rajdhani-Bold.ttf");
font-weight: bold;
font-display: swap;
}
@font-face {
font-family: "Raj";
src: url("/assets/font/rajdhani/Rajdhani-Light.ttf");
font-weight: 200;
font-display: swap;
}
@font-face {
font-family: "Raj";
src: url("/assets/font/rajdhani/Rajdhani-Medium.ttf");
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: "Raj";
src: url("/assets/font/rajdhani/Rajdhani-SemiBold.ttf");
font-weight: 400;
font-display: swap;
}
body {
background: #5e6572;
color: #c9d4e2;
font-family: "Raj", sans-serif;
margin: 0;
padding: 0;
}
.container {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
text-align: left;
}
h1 {
font-size: 64pt;
margin: 0;
padding: 0;
}
h2 {
font-size: 36pt;
padding-top: 70px;
padding-bottom: 0;
margin: 0;
font-weight: 300;
}
h3 {
font-size: 24pt;
text-transform: lowercase;
margin: 0;
padding: 30px 0 0;
font-weight: 300;
}
h4 {
font-size: 18pt;
margin: 0;
padding: 40px 0 0;
font-weight: 300;
}
p {
font-size: 18pt;
font-weight: 300;
}
li {
font-size: 16pt;
font-weight: 300;
}
code {
font-size: 12pt;
background: #3e4552;
padding: 5px;
border-radius: 5px;
}
pre code {
display: block;
}
.inner {
padding-bottom: 10%;
max-width: min(70%, 1000px);
min-height: 100vh;
}
.theme-hide {
display: none !important;
}
button,
a.button,
a.button-small {
border: 1px solid #3e4552;
font-family: "Raj", sans-serif;
font-weight: 300;
font-size: 16pt;
padding: 5px 20px;
border-radius: 5px;
transition: background-color 0.1s linear;
text-decoration: none;
color: #c9d4e2;
background: rgba(0, 0, 0, 0);
}
button:hover,
a.button:hover,
a.button-small:hover {
cursor: pointer;
background: #3e4552;
}
button:active,
a.button:active,
a.button-small:active {
background: #323742;
}
a {
color: #c9d4e2;
}
a.button-small,
button.small {
padding: 1px 10px;
background: #c9d4e2;
border: none;
color: #3e4552;
margin: 5px;
}
a.button-small:hover,
button.small:hover {
color: #c9d4e2;
background: #3e4552;
}
.form-group {
margin: 10px;
}
.form-group label {
font-size: 14pt;
font-weight: 300;
text-transform: lowercase;
}
.form-group input,
.form-group textarea,
.form-group select {
border: 1px solid #3e4552;
border-radius: 5px;
padding: 7px;
font-size: 14pt;
min-width: 400px;
background: rgba(0, 0, 0, 0);
color: #c9d4e2;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: #c9d4e2;
}
footer {
background: #3e4552;
display: flex;
flex-direction: row;
justify-content: center;
padding-bottom: 50px;
}
footer ul {
list-style-type: none;
margin: 0;
padding: 50px 0 0;
}
footer ul li {
display: inline;
padding-left: 20px;
padding-right: 20px;
}
footer ul li a {
color: #a9b4c2;
text-decoration: none;
font-size: 16pt;
font-weight: 300;
transition: all 0.5s;
}
footer ul li a:hover {
color: #ffffff;
}
footer .col {
margin: 0 30px;
}
footer .by-line {
display: flex;
flex-direction: column;
justify-content: center;
font-size: 36pt;
font-weight: 300;
text-align: right;
padding-top: 30px;
}
footer .copy {
font-size: 14pt;
font-weight: 200;
}
footer ul li {
display: block;
margin: 10px 0;
}
footer #tagline:hover {
cursor: pointer;
}
footer .auth-container {
display: flex;
flex-direction: row;
}
footer .auth-container .profile {
margin-right: 15px;
margin-top: 15px;
}
.button-links {
margin: 20px;
}
.button-links a {
margin-right: 10px;
}
@media (max-width: 480px) {
.container {
justify-content: left;
padding: 20px;
width: calc(100% - 40px);
}
.inner {
width: 100%;
max-width: unset;
}
.form-group input,
.form-group textarea,
.form-group select {
min-width: 100%;
}
footer {
flex-direction: column;
}
footer .by-line {
text-align: left;
padding: 30px;
}
footer .links {
padding: 20px 0 0;
}
}
.hero {
margin-top: 100px;
}
.hero h1 {
font-weight: 300;
text-transform: lowercase;
}
.hero h2 {
font-size: 36pt;
padding: 0;
font-weight: 300;
margin: 10px 0 0;
text-transform: lowercase;
}
.hero p {
font-size: 20pt;
}
section#about .about-container {
display: flex;
flex-direction: row;
}
section#about .img {
flex-grow: 0;
flex-shrink: 0;
}
section#about .img img {
max-width: 300px;
margin-top: 30px;
margin-right: 20px;
border-radius: 5px;
}
section#work .timeline-group .timeline-year {
font-size: 18pt;
margin-top: 40px;
}
section#work .timeline-group .timeline-content {
background: #3e4552;
padding: 20px;
border-radius: 5px;
margin: 20px;
}
section#work .timeline-group .timeline-content h3 {
padding: 0;
}
section#work .timeline-group .timeline-content p {
margin: 0;
font-size: 16pt;
}
section#contact .contact-container {
display: flex;
flex-direction: row;
}
section#contact .contact-container .message {
text-align: right;
margin-right: 20px;
}
section#recent .feed-item {
background: #3e4552;
padding: 20px;
margin: 20px;
border-radius: 5px;
}
section#recent .feed-item .feed-category {
display: flex;
}
section#recent .feed-item .feed-category .tag {
background: #5e6572;
padding: 2px 5px;
border-radius: 2px;
font-size: 14pt;
}
section#recent .feed-item .bottom {
display: flex;
flex-direction: row;
justify-content: end;
}
section#recent .feed-item .bottom .stamp {
margin-left: 20px;
font-size: 14pt;
font-weight: 300;
text-align: right;
flex: 1;
}
section#recent a.button {
margin-left: 20px;
}
section#auth {
max-width: 500px;
}
section#auth h3 {
margin-bottom: 30px;
}
@media (max-width: 480px) {
.hero h1 {
font-size: 48pt;
}
section#about .about-container {
display: flex;
flex-direction: column;
}
section#work .timeline-content {
margin: 20px 0 !important;
}
section#contact .contact-container {
display: flex;
flex-direction: column;
}
section#contact .contact-container .message {
text-align: left;
}
section#recent .feed-item {
margin: 20px 0;
}
section#recent a.button {
margin-left: 0;
}
section#recent .bottom {
flex-wrap: wrap;
}
section#recent .bottom .stamp {
max-width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
}
}

View File

@ -0,0 +1,37 @@
window.glmdev = window.glmdev || {}
window.glmdev.taglines = [
'...is proud that this site is Google-free',
'...is a supporter of FLOSS',
'...prefers JavaScript, but Python will do',
'...occasionally rants about dependency injection',
'...loves GNU/Linux',
'(or as I\'ve recently taken to calling it, GNU+Linux)',
'...uses spaces, not tabs',
'...self-hosts all the things',
'...occasionally does contract work',
'...reads the terms and conditions',
'...enjoys r/sysadmin',
'...just lost the game',
'...is running out of tag-line ideas',
`copyright &copy; ${(new Date()).getFullYear()} garrett mills`,
]
document.querySelector('#tagline')
.addEventListener('click', event => {
if ( typeof glmdev.tagline_index === 'undefined' ) glmdev.tagline_index = 0
else if ( glmdev.tagline_index === glmdev.taglines.length - 1 ) glmdev.tagline_index = 0
else glmdev.tagline_index += 1
document.querySelector('#tagline').innerHTML = glmdev.taglines[glmdev.tagline_index]
})
document.querySelector('#timeline-view-all')
.addEventListener('click', event => {
const hidden = document.querySelectorAll('.work-container.theme-hide')
for ( const item of hidden ) {
item.classList.remove('theme-hide')
}
document.querySelector('#timeline-view-all').classList.add('theme-hide')
}, false)

View File

@ -0,0 +1,23 @@
extends template_raj
block content
.container#top
.inner
.hero
h1 garrettmills
section#recent
h2 posts & updates
.button-links
a.button-small(href='/feed/rss.xml') rss
a.button-small(href='/feed/atom.xml') atom
a.button-small(href='/feed/json.json') json
each item in feedPosts
.feed-item
.feed-category(id=item.feedPostId)
.tag #{item.tag}
span
if !item.visible
p.text (draft)
p.text !{item.body}
.bottom
.stamp <a href="#{named('feed')}##{item.feedPostId}" class="feed-edit-button">permalink</a>&nbsp;&nbsp;|&nbsp;&nbsp;#{item.postedAt.toLocaleString()}

View File

@ -0,0 +1,65 @@
doctype html
head
block meta
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='description' content='Hi, there! My name is Garrett. I am a self-taught software developer and speaker.')
meta(name='keywords' content='garrett mills glmdev developer speaker flitter extollo student')
meta(name='author' content=config('app.name', 'Garrett Mills'))
meta(name='robots' content='index, follow')
block title
title #{config('app.name', 'Garrett Mills')} | Developer, Speaker, Designer
block style
link(rel='stylesheet' href=asset('main.css'))
link(rel='author' href='humans.txt')
link(rel="alternate" href="/feed/atom.xml" title="Garrett Mills - Posts & Updates" type="application/atom+xml")
link(rel="alternate" href="/feed/rss.xml" title="Garrett Mills - Posts & Updates" type="application/rss+xml")
body
block content
footer
.ff-tag(style="margin-right: 80px")
a(href="https://www.mozilla.org/en-US/firefox/browsers/" target="_blank")
img(src="/assets/ffox.png" width="75" style="margin-top: -1px")
.by-line garrettmills
.copy#tagline(title="my, aren't you curious...") copyright &copy; #{(new Date()).getFullYear()} garrett mills
if false
div.auth-container
div.profile
img(src=user.profile_url width=50 style="border-radius: 50%")
p Welcome, #{user.first_name} #{user.last_name}&nbsp;&nbsp;|&nbsp;&nbsp;<a href="/auth/logout">Log out</a>
.col
ul.links
li
a(href='/#home') home
li
a(href='/#about') about me
li
a(href='/#work') what I'm working on
.col
ul.links
li
a(href='/#contact') get in touch
li
a(href='/#recent') latest updates
li
a(href='https://static.garrettmills.dev/Resume.pdf' target='_blank') résumé
.col
ul.links
li
a(href='/blog') blog
li
a(href='https://code.garrettmills.dev/garrettmills' target='_blank') my code
li
a(href='/technical') technical info
if false
li
a(href='/dash/main') dashboard
block script
script(src=asset('welcome.js'))

View File

@ -1,6 +1,85 @@
html
head
title !{locale('app_name')}
body
h1 !{locale('welcome_to_extollo')}
h2 !{locale('viewed_page_num_times', { interp: { num: app_visits } })}
extends template_raj
block content
.container#home
.inner
.hero
h1 garrettmills
p Hi, there. My name is Garrett, and I'm a self-taught software-developer and speaker.
section#about
h2 about me
.about-container
.img
img(src="/assets/img/profile.jpg" width=300 height=300)
.about
p Hi! My name is Garrett. Welcome to my corner of the internet. I'm a self-taught developer and maker. I like to build software to improve the developer/user experience. I created the <a href="https://extollo.garrettmills.dev" target="_blank">Extollo</a> framework, <a href="https://code.garrettmills.dev/Noded" target="_blank">Noded</a>, <a href="https://code.garrettmills.dev/starship/coreid" target="_blank">CoreID</a> authentication server, and a couple other projects. I love to communicate my work, and help others pursue their projects. I write blog posts, create video tutorials, hold talks, and publish code from my projects in the hope that others will find it useful.
p A bit more background: I grew up in the rural mid-west, and I got started by teaching myself everything I know. I'm a big fan of learning to code this way. I'm currently studying computer science at the University of Kansas.
if workItemYears && workItemYears.length
section#work
h2 what I'm working on
.timeline-group
each year in workItemYears
.work-container(class=(workItemHiddenYears.includes(year) ? 'timeline-item theme-hide' : 'timeline-item'))
.timeline-container
.timeline-year #{year}
each item in workItemGroups[year]
.work-container(class=(item.endDate ? 'timeline-item theme-hide' : 'timeline-item'))
.timeline-container
.timeline-content
.range-small #{item.rangeDisplay()}
h3 !{item.name}
p !{item.description}
.work-container
button#timeline-view-all show past work
section#contact
h2 get in touch
.contact-container
.message
p I'd love to hear from you if you have questions or inquiries related to me or my projects. You can get in touch by text, e-mail, or using this form. I also occasionally share thoughts on my <a href="/blog">blog</a>.
p <b>E-mail:</b> <a href="mailto:shout@garrettmills.dev">shout@garrettmills.dev</a>
.form
form#contact-form
.form-group
input#contactEmail.form-control(type='email' name='email' placeholder='E-Mail Address' required)
.form-group
input#contactFirst.form-control(name='first' placeholder='First Name' required)
.form-group
input#contactLast.form-control(name='last' placeholder='Last Name' required)
.form-group
textarea.form-control#contactMessage(name='message' placeholder='Message' required rows=6)
.form-group
button Send
section#recent
h2 latest updates
each item in feedPosts
.feed-item
.feed-category(id='feedPostTag_' + item.feedPostId)
.tag #{item.tag}
span
if !item.visible
p.text (draft)
p.text !{item.body}
.bottom
.stamp <a href="#{named('feed')}##{item.feedPostId}" class="feed-edit-button">permalink</a>&nbsp;&nbsp;|&nbsp;&nbsp;#{item.postedAt.toLocaleString()}
// each item in feed_items
// .feed-item
// div.feed-category(id='feedPostTag_' + item.id)
// .tag #{item.tag}
// span
// if item.draft
// p.text (draft)
// p.text !{item.text}
// .bottom
// if user && can_access.feed_delete && can_access.feed_edit
// .stamp <a href="/dash/c/form/FeedPost?id=#{item.id}" class="feed-edit-button" target="_blank">edit</a> | <a href="#recent" class="feed-delete-button" postid="#{item.id}">delete</a>
// .stamp <a href="#{app.url}feed##{item.id}" class="feed-edit-button">permalink</a>&nbsp;&nbsp;|&nbsp;&nbsp;#{item.date.toLocaleString()}
if feed_overflow
.row.mt-4
.col-12.text-center
a.button(href="/feed") view more
block append script
script(src="/assets/js/raj/welcome.js")