projects page and unicord post

This commit is contained in:
2026-08-23 22:03:49 -05:00
parent 8322e0275b
commit 30d33077ec
18 changed files with 795 additions and 106 deletions
+8
View File
@@ -370,6 +370,12 @@ code {
background: var(--code-bg);
padding: 0.12em 0.35em;
border-radius: 3px;
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
vertical-align: baseline;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
pre {
@@ -383,6 +389,8 @@ pre {
}
pre code {
display: inline;
max-width: none;
padding: 0;
color: inherit;
background: none;
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
images?: Array<{ src: string; alt?: string }> | null;
decorative?: boolean;
}>(),
{
decorative: false,
},
);
const frames = computed(() => props.images ?? []);
</script>
<template>
<div v-if="frames.length" class="carousel">
<figure
v-for="image in frames"
:key="image.src"
class="carousel__frame"
>
<img :src="image.src" :alt="decorative ? '' : (image.alt ?? '')" />
</figure>
</div>
</template>
<style scoped>
.carousel {
--dot-color: #666;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: var(--gap);
overflow-x: auto;
overflow-y: hidden;
margin: 0.85rem 0 0;
padding-bottom: 0.35rem;
scroll-snap-type: x mandatory;
}
.carousel__frame {
--dot-size: 6%;
margin: 0;
padding: var(--gap);
flex-shrink: 0;
border-radius: var(--radius);
background-color: var(--bg-secondary);
background-image: radial-gradient(
circle at 4px 4px,
var(--dot-color) var(--dot-size),
transparent calc(var(--dot-size) + 1%)
);
background-position: 4px 4px;
background-size: 12px 12px;
background-repeat: repeat;
display: flex;
align-items: center;
justify-content: center;
scroll-snap-align: start;
}
.carousel__frame img {
max-width: none;
width: auto;
height: auto;
max-height: 350px;
object-fit: cover;
border-radius: var(--radius);
background: none;
}
@media screen and (max-width: 750px) {
.carousel__frame img {
max-height: 300px;
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { isExternalHref, projectEntryHref } from '~/utils/projects';
const props = defineProps<{
pages: Array<{
path: string;
title: string;
description: string;
href?: string | null;
image?: string | null;
}>;
}>();
const entries = computed(() =>
props.pages.map((page) => {
const to = projectEntryHref(page);
const external = isExternalHref(to);
return { page, to, external };
}),
);
onMounted(() => {
for (const { image } of props.pages) {
if (!image) continue;
new Image().src = image;
}
});
const preview = ref<string | null>(null);
const cursor = ref({ x: 0, y: 0 });
const previewEl = ref<HTMLElement | null>(null);
const previewStyle = computed(() => {
const pad = 16;
const ox = 20;
const oy = 20;
const el = previewEl.value;
const w = el?.offsetWidth ?? 320;
const h = el?.offsetHeight ?? 200;
let x = cursor.value.x + ox;
let y = cursor.value.y + oy;
if (import.meta.client) {
if (x + w > window.innerWidth - pad) x = cursor.value.x - w - ox;
if (y + h > window.innerHeight - pad) y = cursor.value.y - h - oy;
}
x = Math.max(pad, x);
y = Math.max(pad, y);
return {
transform: `translate3d(${x}px, ${y}px, 0)`,
};
});
function onMove(event: MouseEvent, image: string) {
if (!image) return;
preview.value = image;
cursor.value = { x: event.clientX, y: event.clientY };
}
function onLeave() {
preview.value = null;
}
</script>
<template>
<template v-if="entries.length">
<NuxtLink
v-for="{ page, to, external } in entries"
:key="page.path"
:to="to"
:external="external"
:target="external ? '_blank' : undefined"
:rel="external ? 'noopener' : undefined"
class="project"
@mousemove="page.image ? onMove($event, page.image) : undefined"
@mouseleave="page.image ? onLeave() : undefined"
>
<span class="project__title">
{{ page.title }}
<span v-if="external" class="project__ext" aria-hidden="true"
>↗</span
>
</span>
<span class="project__desc">{{ page.description }}</span>
</NuxtLink>
<Teleport to="body">
<div
v-show="preview"
ref="previewEl"
class="project-preview"
aria-hidden="true"
:style="previewStyle"
>
<img v-if="preview" :src="preview" alt="" />
</div>
</Teleport>
</template>
<p v-else class="empty-state">Nothing here yet.</p>
</template>
<style scoped>
.project {
display: block;
margin: 0 0 1.15rem;
text-decoration: none;
color: inherit;
}
.project:hover {
text-decoration: none;
}
.project:hover .project__title {
text-decoration: underline;
text-underline-offset: 3px;
}
.project__title {
display: block;
color: var(--text);
font-size: 1.15rem;
line-height: 1.3;
}
.project__ext {
font-size: 0.75em;
color: var(--muted);
}
.project__desc {
display: block;
margin: 0.35rem 0 0;
color: var(--muted);
}
.empty-state {
color: var(--muted);
font-style: italic;
}
.project-preview {
position: fixed;
top: 0;
left: 0;
z-index: 40;
pointer-events: none;
will-change: transform;
}
.project-preview img {
display: block;
width: auto;
height: auto;
max-width: min(25rem, 80vw);
max-height: min(20rem, 50vh);
object-fit: cover;
border: 1px solid color-mix(in srgb, var(--text) 40%, transparent);
border-radius: var(--radius);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.28),
0 18px 40px rgba(0, 0, 0, 0.42);
}
@media (hover: none) {
.project-preview {
display: none;
}
}
</style>
+28 -43
View File
@@ -1,14 +1,8 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string;
description?: string;
}>(),
{
title: "Hey, I'm Jaron.",
description: 'Software engineer by trade — builder by nature.',
},
);
defineProps<{
title: string;
description: string;
}>()
const { id, themes, setTheme } = useBgTheme();
const { id: fontId, fonts, setFont } = useBodyFont();
@@ -36,40 +30,25 @@ const onFontChange = (event: Event) => {
</NuxtLink>
<nav class="site-nav">
<NuxtLink to="/writing">Writing</NuxtLink>
<NuxtLink to="/projects">Projects</NuxtLink>
<a href="https://github.com/jaronpate">GitHub</a>
<label class="theme-picker">
<span class="visually-hidden">Background</span>
<select
class="theme-select"
:value="id"
@change="onThemeChange"
>
<option
v-for="theme in themes"
:key="theme.id"
:value="theme.id"
>
{{ theme.label }}
</option>
</select>
</label>
<!-- <label class="theme-picker">
<span class="visually-hidden">Font</span>
<select
class="theme-select"
:value="fontId"
@change="onFontChange"
>
<option
v-for="font in fonts"
:key="font.id"
:value="font.id"
>
{{ font.label }}
</option>
</select>
</label> -->
</nav>
<label class="theme-picker">
<span class="visually-hidden">Background</span>
<select
class="theme-select"
:value="id"
@change="onThemeChange"
>
<option
v-for="theme in themes"
:key="theme.id"
:value="theme.id"
>
{{ theme.label }}
</option>
</select>
</label>
</div>
<h1 class="hero__title">{{ title }}</h1>
<p v-if="description" class="hero__sub">{{ description }}</p>
@@ -127,6 +106,7 @@ const onFontChange = (event: Event) => {
.hero__nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
margin-bottom: 3.5rem;
@@ -160,6 +140,7 @@ const onFontChange = (event: Event) => {
.site-nav {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
margin-left: auto;
@@ -290,10 +271,14 @@ const onFontChange = (event: Event) => {
}
.hero__nav {
flex-wrap: wrap;
margin-bottom: 1.75rem;
}
.theme-picker {
flex-basis: 100%;
justify-content: flex-end;
}
.hero__title {
font-size: 2rem;
}
+12 -57
View File
@@ -92,7 +92,8 @@ const selectedWorks = [
const projects = [
{
title: 'Unicord',
href: 'https://unicord.hat.fish/',
// href: 'https://unicord.hat.fish/',
href: '/projects/unicord',
description: 'Discord bot SDK inspired by webserver architecture.',
},
{
@@ -102,10 +103,10 @@ const projects = [
'An AI-driven mystery detective game. Generate, investigate, solve.',
},
{
title: 'Rune Buddy',
title: 'Rift Buddy',
href: 'https://github.com/jaronpate/rift-buddy',
description:
'League of Legends runepage manager. Save & organise your pages for free.',
'Save, edit, and hot-swap rune pages alongside League of Legends.',
},
];
@@ -178,7 +179,9 @@ definePageMeta({
</script>
<template>
<NuxtLayout name="main">
<NuxtLayout name="main"
title="Hey, I'm Jaron"
description="I build software for a living, but also other things">
<article>
<h3>Selected Work</h3>
<template v-for="work in selectedWorks" :key="work.title">
@@ -188,14 +191,8 @@ definePageMeta({
}}</a>:
{{ work.description }}
</p>
<div class="work__images">
<figure
v-for="image in work.images"
:key="image.src"
class="work__image"
>
<img :src="image.src" :alt="image.alt" />
</figure>
<div class="work-carousel">
<Carousel :images="work.images" />
</div>
</template>
@@ -203,7 +200,7 @@ definePageMeta({
<ul>
<li v-for="project in projects" :key="project.href">
<p>
<a :href="project.href" target="_blank" rel="noopener">{{
<a :href="project.href">{{
project.title
}}</a>:
{{ project.description }}
@@ -240,53 +237,11 @@ definePageMeta({
</template>
<style scoped>
.work__images {
--dot-color: #666;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: var(--gap);
overflow-x: auto;
overflow-y: hidden;
.work-carousel {
margin: 1.25rem 0 1.75rem;
padding-bottom: 0.35rem;
}
.work__image {
--dot-size: 6%;
.work-carousel :deep(.carousel) {
margin: 0;
padding: var(--gap);
flex-shrink: 0;
border-radius: var(--radius);
background-color: var(--bg-secondary);
background-image: radial-gradient(
circle at 4px 4px,
var(--dot-color) var(--dot-size),
transparent calc(var(--dot-size) + 1%)
);
background-position: 4px 4px;
background-size: 12px 12px;
background-repeat: repeat;
display: flex;
align-items: center;
justify-content: center;
}
.work__image img {
max-width: none;
width: auto;
height: auto;
max-height: 350px;
object-fit: cover;
border-radius: var(--radius);
background: none;
}
@media screen and (max-width: 750px) {
.work__image img {
max-height: 300px;
}
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script lang="ts" setup>
import {
DEFAULT_IMAGE,
SITE_NAME,
SITE_URL,
personId,
websiteId,
} from '~/utils/site';
import {
isExternalHref,
projectEntryHref,
projectOgImage,
} from '~/utils/projects';
const route = useRoute();
const pageUrl = `${SITE_URL}${route.path}`;
const webpageId = `${pageUrl}#webpage`;
const workId = `${pageUrl}#work`;
const slug = computed(() => {
const raw = Array.isArray(route.params.slug)
? route.params.slug.join('/')
: String(route.params.slug ?? '');
return raw.replace(/\/+$/, '');
});
const contentPath = computed(() => '/' + slug.value);
const folderName = computed(
() => slug.value.split('/').filter(Boolean).at(-1) ?? slug.value,
);
const { data } = await useAsyncData(
() => `projects:${route.path}`,
async () => {
const all = await queryCollection('projects').order('title', 'ASC').all();
const visible = all.filter((page) => import.meta.dev || !page.hidden);
const project = visible.find(
(page) =>
page.path === contentPath.value || page.stem === slug.value,
);
if (project) {
return { kind: 'project' as const, project, pages: [] };
}
const prefix = contentPath.value.endsWith('/')
? contentPath.value
: `${contentPath.value}/`;
const pages = visible.filter((page) => page.path.startsWith(prefix));
if (!pages.length) return null;
return { kind: 'folder' as const, project: null, pages };
},
{ watch: [() => route.path] },
);
if (!data.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
const page = computed(() => data.value?.project ?? null);
const isFolder = computed(() => data.value?.kind === 'folder');
const projectTitle = page.value?.title ?? folderName.value;
const projectDescription = page.value?.description ?? '';
const liveHref = computed(() => page.value?.href ?? '');
const ogImage = projectOgImage(
page.value?.image,
SITE_URL,
DEFAULT_IMAGE,
);
useSeoMeta({
title: projectTitle,
ogTitle: projectTitle,
description: projectDescription || projectTitle,
ogDescription: projectDescription || projectTitle,
ogUrl: pageUrl,
ogType: isFolder.value ? 'website' : 'article',
ogImage,
ogImageAlt: projectTitle,
twitterCard: 'summary',
twitterTitle: projectTitle,
twitterDescription: projectDescription || projectTitle,
twitterImage: ogImage,
});
useHead({
link: [{ rel: 'canonical', href: pageUrl }],
script: page.value
? [
{
key: 'json-ld-project',
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebPage',
'@id': webpageId,
url: pageUrl,
name: projectTitle,
description: projectDescription,
inLanguage: 'en-US',
isPartOf: { '@id': websiteId },
breadcrumb: { '@id': `${pageUrl}#breadcrumb` },
mainEntity: { '@id': workId },
},
{
'@type': 'CreativeWork',
'@id': workId,
url: isExternalHref(projectEntryHref(page.value))
? projectEntryHref(page.value)
: pageUrl,
name: projectTitle,
description: projectDescription,
image: {
'@type': 'ImageObject',
'@id': `${pageUrl}#work-image`,
url: ogImage,
},
inLanguage: 'en-US',
mainEntityOfPage: { '@id': webpageId },
author: {
'@type': 'Person',
'@id': personId,
name: SITE_NAME,
url: SITE_URL,
},
creator: {
'@type': 'Person',
'@id': personId,
name: SITE_NAME,
},
},
{
'@type': 'BreadcrumbList',
'@id': `${pageUrl}#breadcrumb`,
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: SITE_URL,
},
{
'@type': 'ListItem',
position: 2,
name: 'Projects',
item: `${SITE_URL}/projects`,
},
{
'@type': 'ListItem',
position: 3,
name: projectTitle,
item: pageUrl,
},
],
},
],
}),
},
]
: [],
});
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout
name="main"
:title="page ? page.title : folderName"
:description="
page ? page.description : `Projects from ${folderName}`
"
>
<article v-if="page">
<div class="article-body">
<p v-if="page.description">
<strong>{{ page.description }}</strong>
</p>
<ContentRenderer :value="page" />
<p v-if="liveHref">
<a
:href="liveHref"
target="_blank"
rel="noopener"
>See it live ↗</a
>
</p>
</div>
</article>
<article v-else>
<ProjectList :pages="data?.pages ?? []" />
</article>
</NuxtLayout>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import {
DEFAULT_IMAGE,
SITE_NAME,
SITE_URL,
personId,
websiteId,
} from '~/utils/site';
import { isExternalHref, projectEntryHref } from '~/utils/projects';
const { data: pages } = await useAsyncData('project-pages', async () => {
const all = await queryCollection('projects').order('title', 'ASC').all();
return import.meta.dev ? all : all.filter((page) => !page.hidden);
});
const projectsUrl = `${SITE_URL}/projects`;
const collectionId = `${projectsUrl}#collection`;
const listId = `${projectsUrl}#list`;
const projectsTitle = 'Projects';
const projectsDescription =
"Things I've made. Some for work, some because I wanted them to exist.";
useSeoMeta({
title: projectsTitle,
ogTitle: projectsTitle,
description: projectsDescription,
ogDescription: projectsDescription,
ogUrl: projectsUrl,
ogType: 'website',
ogImage: DEFAULT_IMAGE,
ogImageAlt: projectsTitle,
twitterCard: 'summary',
twitterTitle: projectsTitle,
twitterDescription: projectsDescription,
twitterImage: DEFAULT_IMAGE,
});
useHead({
link: [{ rel: 'canonical', href: projectsUrl }],
script: [
{
key: 'json-ld-projects',
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'CollectionPage',
'@id': collectionId,
url: projectsUrl,
name: projectsTitle,
description: projectsDescription,
inLanguage: 'en-US',
isPartOf: { '@id': websiteId },
author: {
'@type': 'Person',
'@id': personId,
name: SITE_NAME,
},
mainEntity: { '@id': listId },
breadcrumb: { '@id': `${collectionId}#breadcrumb` },
},
{
'@type': 'ItemList',
'@id': listId,
name: 'Projects by Jaron Pate',
itemListElement: (pages.value ?? []).map(
(page, index) => {
const href = projectEntryHref(page);
const url = isExternalHref(href)
? href
: `${SITE_URL}${href}`;
return {
'@type': 'ListItem',
position: index + 1,
url,
name: page.title,
};
},
),
},
{
'@type': 'BreadcrumbList',
'@id': `${collectionId}#breadcrumb`,
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: SITE_URL,
},
{
'@type': 'ListItem',
position: 2,
name: projectsTitle,
item: projectsUrl,
},
],
},
],
}),
},
],
});
definePageMeta({
layout: false,
});
</script>
<template>
<NuxtLayout
name="main"
title="Projects"
description="Software engineer by trade — builder by nature."
>
<article>
<ProjectList :pages="pages ?? []" />
</article>
</NuxtLayout>
</template>
+20
View File
@@ -0,0 +1,20 @@
export function projectEntryHref(page: {
path: string;
href?: string | null;
}) {
return page.href || `/projects${page.path}`;
}
export function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
export function projectOgImage(
src: string | undefined,
siteUrl: string,
fallback: string,
) {
if (!src) return fallback;
if (/^https?:\/\//i.test(src)) return src;
return `${siteUrl}${src.startsWith('/') ? src : `/${src}`}`;
}
+6 -5
View File
@@ -1,15 +1,16 @@
export const BG_THEMES = [
{ id: 'dusk', label: 'Dusk' },
{ id: 'paper', label: 'Paper' },
{ id: 'navy', label: 'Navy' },
{ id: 'midnight', label: 'Midnight' },
{ id: 'indigo', label: 'Indigo' },
{ id: 'storm', label: 'Storm' },
{ id: 'slate', label: 'Slate' },
{ id: 'ink', label: 'Ink' },
{ id: 'pine', label: 'Pine' },
{ id: 'wine', label: 'Wine' },
{ id: 'cocoa', label: 'Cocoa' },
{ id: 'paper', label: 'Paper' },
// Disabled for now, I don't like them as much
// { id: 'storm', label: 'Storm' },
// { id: 'pine', label: 'Pine' },
// { id: 'wine', label: 'Wine' },
// { id: 'cocoa', label: 'Cocoa' },
] as const;
export type BgThemeId = (typeof BG_THEMES)[number]['id'];
+14
View File
@@ -15,5 +15,19 @@ export default defineContentConfig({
hidden: z.boolean().optional(),
}),
}),
projects: defineCollection({
type: 'page',
source: {
include: 'projects/**/*.md',
prefix: '',
},
schema: z.object({
title: z.string(),
description: z.string(),
href: z.string().optional(),
hidden: z.boolean().optional(),
image: z.string().optional(),
}),
}),
},
});
+5
View File
@@ -0,0 +1,5 @@
---
title: Mystwright
description: An AI-driven mystery detective game. Generate, investigate, solve.
href: https://mystwright.com/
---
+6
View File
@@ -0,0 +1,6 @@
---
title: Rift Buddy
description: Save, edit, and hot-swap rune pages alongside League of Legends.
href: https://github.com/jaronpate/rift-buddy
image: /projects/rift-buddy/demo.gif
---
+130
View File
@@ -0,0 +1,130 @@
---
title: Unicord 🦄
description: A Discord bot SDK that works like a web server.
# href: https://unicord.hat.fish
---
I wanted writing a Discord bot to feel like writing a small web app. Register a handler, get a request, send a reply. *Bang!...bot done.* This became particularly interesting once bots could be served over HTTP.
[Unicord 🦄](https://unicord.hat.fish/) is a TypeScript SDK for that. One client, one command API. A chat command and a slash command can share the same function.
You dont have to choose between the gateway, webhooks, application commands, or fiddle with file based command registration; *just write the damn code!*
A whole bot can look like this:
```typescript
import { Client, type Context } from 'unicord';
// Initialize and configure
const client = new Client({
token: '<token>',
application_id: '<application_id>',
intents: [Intent.ALL],
prefix: '??'
});
// Register arbitrary event handler to know when we are logged in
client.on('READY', async (_, payload) => {
console.log(`Logged in as ${payload.user.username}${payload.user.discriminator?.length > 0 ? `#${payload.user.discriminator}` : ''}`);
});
// Register a command
client.chatCommands.register('ping', (context: Context, args) => {
context.reply('Pong!', true);
});
// Connect!
client.connect();
```
**Boom** done. We are Pong-ing all the Pings!
---
My favorite part to build and use is the ✨\~Hydrator\~✨. :margin-note[(Sounds fancy right? 💪😎)] When handling some interaction the client assumes you want the least data neccessary. Then say you want to know the guild, for instance, you can request it be "Hydrated".
```typescript
client.chatCommands.register('guild', async (context: Context, _args: any[]) => {
const hydrate = await context.hydrator([Expectation.Guild]);
const hasGuild = hydrate(context);
const { message } = context;
if (hasGuild) {
await context.reply(`You are in ${message.guild.name}`, true);
} else {
await context.reply(`We are not in a server`, true);
}
});
```
> If the requested data has been seen recently it is simply filled in from the local cache, otherwise it is fetched from the API.
You'll notice that you also recieve a boolean from the hydrator. This boolean indicates to the type system if hydration was sucessful. :margin-note[(a.k.a it returns a type assertion!)]
::notice
For the uninitiated: this means for if statment that follows inside the true case `message.guild` will be typed as `Guild` and in the false case as `undefined`!
Huzzah! Type safety! 🍻
::
---
But wait there's more!?
What about user input? Can't trust it? Has to be validated? A tale as old as time...however old time is.
Fear no more Unicord can also **validate**, **resolve**, and **type** your command arguments!
Check it out:
```typescript
// First we define a commmand with the helper
const avatarCommand = createCommandHandler({
description: 'Get a users avatar',
// In the definition we provide our arguments in order:
args: [
{
id: 'user',
name: 'user',
// And here we inform the client what type we expect
type: CommandOptionType.User,
description: 'The user to get the avatar for',
required: true
}
],
execute: async (context: Context, args) => {
// Now inside of our execution function `args`
// will have the type: `{ user: User }`
context.reply(args.user.avatarURL);
}
});
```
And just like that when our function is called the first argument is translated into a Discord `User` object! :margin-note[Nifty!]
> Many different option types exist. You can even have an Enum. When constraints are known the Unicord client can auto reply to bad command usage with an error message and the valid values allowed!
<!--
::notice
Many different option types exist. You can even have an Enum. When constraints are known the Unicord client can auto reply to bad command usage with an error message and the valid values allowed!
::-->
To finish out the example — you would then register your command handler:
```typescript
// Either as a plain ol' chat command
client.chatCommands.register('avatar', avatarCommand);
// Or a fancy slash command
client.applicationCommands.register('avatar', ApplicationCommandType.Chat, avatarCommand);
```
And use it in Discord like so:
![Example command used in Discord](/projects/unicord/command-example-discord.png)
---
It's pretty unfinished and out of date so not everything works, but I think this first spike really shows some potential in the idea! And it's fun to write imo.
Source is on [the hub](https://github.com/jaronpate/unicord) and site is [here](https://unicord.hat.fish).
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+1 -1
View File
@@ -350,7 +350,7 @@
<article class="project">
<h3>
<a href="https://github.com/jaronpate/rift-buddy"
>Rune Buddy</a
>Rift Buddy</a
>
</h3>
<p>