Initial Commit
All checks were successful
ci / build-ui (push) Successful in 16s
ci / publish (push) Successful in 22s

This commit is contained in:
Matthew McPeak 2026-10-08 08:50:57 -04:00
commit 1990111156
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
72 changed files with 3192 additions and 0 deletions

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

74
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,74 @@
name: ci
on:
push:
tags:
- "v*"
pull_request:
jobs:
build-ui:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install
- name: Audit dependencies
run: bun audit || true
- name: Type check
run: bun run type-check
- name: Build
run: bun run build-only
- name: Upload Artifact
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: build-output.zip
path: dist/
publish:
needs: [build-ui]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.BUN_AUTH_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download Artifact
uses: https://code.forgejo.org/forgejo/download-artifact@v4
with:
name: build-output.zip
path: dist
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
git.mcpeakdev.com/mcpeakdev/portfolio:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakdev/portfolio:latest
cache-from: type=registry,ref=git.mcpeakdev.com/mcpeakdev/portfolio:latest
cache-to: type=inline

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

5
.oxfmtrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}

10
.oxlintrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

8
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode"
]
}

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp && \
chown -R 101:101 /var/cache/nginx /etc/nginx/conf.d && \
sed -i -e '/^user /d' -e 's|^pid .*|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \
printf '%s\n' \
'server {' \
' listen 8080;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' server_tokens off;' \
' gzip on;' \
' gzip_types text/css application/javascript application/json image/svg+xml;' \
' location = /healthz { access_log off; return 200 "ok\n"; }' \
' location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; }' \
' location = /index.html { add_header Cache-Control "no-cache"; }' \
' location / { try_files $uri $uri/ /index.html; }' \
'}' > /etc/nginx/conf.d/site.conf
COPY dist/ /usr/share/nginx/html/
EXPOSE 8080
USER 101
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

1452
bun.lock Normal file

File diff suppressed because it is too large Load diff

24
components.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

1
env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

26
eslint.config.ts Normal file
View file

@ -0,0 +1,26 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

23
index.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matthew McPeak - Full-Stack Engineer</title>
<meta name="description" content="Full-stack engineer in Kingsport, TN. Six years building SaaS for county clerks across Tennessee. Vue, TypeScript, Go.">
<meta property="og:title" content="Matthew McPeak - Full-Stack Engineer">
<meta property="og:description" content="Full-stack engineer in Kingsport, TN. Vue, TypeScript, Go.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://portfolio.mcpeakdev.com/">
<meta property="og:image" content="https://api.mcpeakdev.com/api/cdn/selfie.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="google-site-verification" content="wF4Cb06PaQIgplh5RwvS07BHxcOHDCShokuEoIzTm6o" />
<link rel="canonical" href="https://portfolio.mcpeakdev.com/">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

54
package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "portfolio",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/"
},
"dependencies": {
"@lucide/vue": "^1.52.0",
"@tailwindcss/vite": "^4.3.3",
"@vueuse/core": "^15.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"reka-ui": "^2.11.0",
"shadcn-vue": "^2.8.2",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.5",
"@types/node": "^26.6.4",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.82.0",
"eslint-plugin-vue": "~10.11.0",
"jiti": "^2.7.0",
"npm-run-all2": "^9.0.3",
"oxfmt": "^0.67.0",
"oxlint": "~1.82.0",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.0",
"vite": "^8.2.2",
"vite-plugin-vue-devtools": "^8.2.1",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

9
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://portfolio.mcpeakdev.com/sitemap.xml

35
src/App.vue Normal file
View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import AppFooter from '@/components/AppFooter.vue';
import AppHeader from '@/components/AppHeader.vue';
import SocialLinks from './components/SocialLinks.vue';
</script>
<template>
<div class="flex flex-col h-auto md:h-screen scroll-smooth">
<a href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-background focus:text-foreground focus:px-4 focus:py-2 focus:rounded-md focus:outline-2">
Skip to content
</a>
<header class="sticky top-0 z-50">
<AppHeader />
</header>
<main class="px-4 py-4 flex-grow" >
<div class="flex flex-col md:flex-row gap-10 justify-center h-full">
<div class="flex flex-col md:flex-row place-self-center">
<div class="flex flex-col gap-3">
<img class="sepia-70 h-96 w-auto aspect-[1.21] object-cover rounded-md " src="https://api.mcpeakdev.com/api/cdn/selfie.jpg" alt="Matthew McPeak"/>
<SocialLinks />
</div>
</div>
<div class="flex flex-col gap-2 justify-center basis-8/12 px-2 md:px-0 ">
<RouterView />
</div>
</div>
</main>
<footer class="md:sticky md:bottom-0" >
<AppFooter />
</footer>
</div>
</template>
<style scoped></style>

140
src/assets/index.css Normal file
View file

@ -0,0 +1,140 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-vue/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.2708 0.1111 29.23);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.2708 0.1111 29.23);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.2708 0.1111 29.23);
--accent-foreground: oklch(0.967 0.001 286.375);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.2708 0.1111 29.23);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.2708 0.1111 29.23);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.2708 0.1111 29.23);
--primary-foreground:oklch(0.92 0.004 286.32);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.2708 0.1111 29.23);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.2708 0.1111 29.23);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.2708 0.1111 29.23);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
svg[role="img"], svg[role="header"] {
fill: var(--foreground)
}
*:focus > svg[role="img"], *:hover > svg[role="img"] {
fill: var(--primary-foreground)
}
@layer base {
* {
@apply border-border outline-ring/50 transition-colors duration-300;
}
body {
@apply bg-background text-foreground;
}
a, button {
@apply hover:!bg-accent focus:!bg-accent focus:!text-primary-foreground hover:!text-primary-foreground;
}
}
@media (prefers-reduced-motion: reduce) {
.animate-in, .animate-out { animation: none !important; transition: none !important; }
}

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import {
Card,
CardContent
} from '@/components/ui/card'
import SocialLinks from './SocialLinks.vue';
import { CopyrightIcon } from '@lucide/vue'
import ThemeToggleBtn from './ThemeToggleBtn.vue';
</script>
<template>
<Card class="rounded-none py-3 z-50">
<CardContent class="flex justify-center md:justify-between flex-row animate-in slide-in-from-bottom duration-500">
<div class="flex p-2 justify-center text-muted-foreground w-full md:w-auto">
<CopyrightIcon :size="15" class="mt-1 mr-1"/>
<p class="text-sm">Matthew McPeak</p>
</div>
<div class="justify-end basis-3/12 hidden md:flex">
<ThemeToggleBtn />
</div>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, type Component } from "vue"
import { useColorMode } from "@vueuse/core"
import { Button } from '@/components/ui/button'
import {
Card,
CardContent
} from '@/components/ui/card'
import NavSheet from "@/components/NavSheet.vue"
import Nav from "@/components/Nav.vue"
import TitleName from "@/components/TitleName.vue"
import ThemeToggleBtn from "@/components/ThemeToggleBtn.vue"
</script>
<template>
<Card class="rounded-none py-3 z-50">
<CardContent class="flex justify-between px-3 animate-in slide-in-from-top duration-500">
<div class="flex gap-3 justify-start ">
<NavSheet class="md:hidden"/>
<TitleName />
</div>
<div class="flex gap-3 justify-end ">
<Nav aria-label="Main" class="hidden md:inline" />
<div class="md:hidden flex">
<ThemeToggleBtn />
</div>
</div>
</CardContent>
</Card>
</template>

63
src/components/Nav.vue Normal file
View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import { RouterLink, useRoute } from "vue-router";
import Button from "@/components/ui/button/Button.vue";
import { Routes } from "@/types/routes";
import { Home, Pencil, BriefcaseBusiness} from '@lucide/vue';
interface Props {
orientation?: "horizontal" | "vertical"
}
const currentRoute = useRoute()
const { orientation = "horizontal" } = defineProps<Props>()
const emit = defineEmits<{"route:clicked": []}>()
const isVertical = computed(() => orientation === "horizontal" ? false : true);
const routes = ref( [
{
icon: Home,
title: "Home",
url: Routes.Home
},
{
icon: Pencil,
title: "Skills",
url: Routes.Skills
},
{
icon: BriefcaseBusiness,
title: "Work",
url: Routes.Work
}
])
const handleNavDrawer = () => {
emit("route:clicked");
}
</script>
<template>
<nav>
<div class="flex gap-3 items-start" :class="{ 'flex-col': isVertical }">
<template v-for="route in routes" :key="route.title">
<Button
:as="RouterLink"
:class="{ '!bg-accent !text-primary-foreground': currentRoute.path === route.url, 'w-full': isVertical }"
:variant="isVertical ? 'outline' : 'ghost'"
@click="handleNavDrawer"
:to="route.url"
:title="route.title"
>
<component :is="route.icon" />
<p class="text-sm">{{ route.title }}</p>
</Button>
</template>
</div>
</nav>
</template>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetFooter,
} from '@/components/ui/sheet'
import Nav from '@/components/Nav.vue';
import TitleName from '@/components/TitleName.vue';
import SocialLinks from "@/components/SocialLinks.vue"
import { Menu, X } from '@lucide/vue';
const open = ref<boolean>(false)
const closeSheet = () => open.value = false
</script>
<template>
<div>
<Sheet v-bind="{open}">
<SheetTrigger as-child>
<Button variant="ghost" @click="open = true" title="Navigation Menu">
<Menu />
</Button>
</SheetTrigger>
<SheetContent
side="left"
class="[&>button]:hidden"
:onOpenAutoFocus="(e: Event) => e.preventDefault()"
:onCloseAutoFocus="(e: Event) => e.preventDefault()"
>
<SheetHeader class="border-b-1 py-3">
<SheetTitle class="flex justify-between">
<TitleName />
<Button variant="ghost" @click="closeSheet" title="Close Navigation Menu">
<X />
</Button>
</SheetTitle>
</SheetHeader>
<div class="px-4">
<Nav aria-label="Mobile" orientation="vertical" @route:clicked="closeSheet" />
</div>
</SheetContent>
</Sheet>
</div>
</template>

View file

@ -0,0 +1,58 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import { Button } from '@/components/ui/button'
import Github from "@/components/icons/Github.vue";
import LinkedIn from "@/components/icons/LinkedIn.vue";
import ForgeJo from "@/components/icons/ForgeJo.vue";
import { Mail } from "@lucide/vue"
const emit = defineEmits<{"route:clicked": []}>()
const buttons = shallowRef( [
{
title: "Github",
icon: Github,
url: "https://github.com/McPeakDev"
},
{
title: "Forgejo",
icon: ForgeJo,
url: "https://git.mcpeakdev.com/McPeakML"
},
{
title: "LinkedIn",
icon: LinkedIn,
url: "https://linkedin.com/in/matthewmcpeak"
},
{
title: "Email",
icon: Mail,
url: "mailto:m.mcpeak98@icloud.com"
}
])
const handleNavDrawer = () => {
emit('route:clicked')
}
</script>
<template>
<div class="flex gap-3 justify-center flex-row w-full">
<Button
as="a"
v-for="button in buttons"
:key="button.title"
variant="outline"
:title="button.title"
:href="button.url"
@click="handleNavDrawer"
>
<component :is="button.icon" />
<p class="text-sm">{{ button.title }}</p>
</Button>
</div>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, type Component } from "vue"
import { useColorMode } from "@vueuse/core"
import { Button } from '@/components/ui/button'
import { Sun, Moon } from '@lucide/vue'
import { Theme } from "@/types/theme"
const mode = useColorMode({
disableTransition: false
})
const theme = computed<Theme>(() => getSystemTheme() === "light" ? Theme.Light : Theme.Dark)
const themeIcon = computed<Component>(() => theme.value === Theme.Light ? Sun : Moon)
const themeBtnText = computed<string>(() => theme.value === Theme.Light ? "Light" : "Dark")
const getSystemTheme = () => (mode.store.value === 'auto' ? mode.system.value : mode.store.value)
const toggleTheme = () => {
mode.value = theme.value === Theme.Light ? 'dark' : 'light'
}
</script>
<template>
<Button class="cursor-pointer" variant="ghost" @click="toggleTheme" :title="`Toggle ${ themeBtnText } Theme`">
<component :is="themeIcon"/>
<p class="hidden md:inline">{{ themeBtnText }}</p>
</Button>
</template>

View file

@ -0,0 +1,3 @@
<template>
<p class="font-bold text-2xl my-1 antialiased">Matthew McPeak</p>
</template>

View file

@ -0,0 +1,3 @@
<template>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Forgejo</title><path d="M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"/></svg>
</template>

View file

@ -0,0 +1,3 @@
<template>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
</template>

View file

@ -0,0 +1,3 @@
<template>
<svg role="img" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn</title><path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"/></svg>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,40 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>

View file

@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuRoot,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import NavigationMenuViewport from "./NavigationMenuViewport.vue"
const props = withDefaults(defineProps<NavigationMenuRootProps & {
class?: HTMLAttributes["class"]
viewport?: boolean
}>(), {
viewport: true,
})
const emits = defineEmits<NavigationMenuRootEmits>()
const delegatedProps = reactiveOmit(props, "class", "viewport")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="cn('group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', props.class)"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NavigationMenuContentEmits, NavigationMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<NavigationMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
props.class,
)"
>
<slot />
</NavigationMenuContent>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NavigationMenuIndicatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { NavigationMenuIndicator, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuIndicatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<NavigationMenuIndicator
data-slot="navigation-menu-indicator"
v-bind="forwardedProps"
:class="cn('data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden', props.class)"
>
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuIndicator>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { NavigationMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { NavigationMenuItem } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<slot />
</NavigationMenuItem>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { NavigationMenuLinkEmits, NavigationMenuLinkProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuLink,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuLinkProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<NavigationMenuLinkEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NavigationMenuLink
data-slot="navigation-menu-link"
v-bind="forwarded"
:class="cn(`data-active:focus:bg-accent data-active:hover:bg-accent data-active:bg-accent/50 data-active:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4`, props.class)"
>
<slot />
</NavigationMenuLink>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { NavigationMenuListProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { NavigationMenuList, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuListProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
props.class,
)
"
>
<slot />
</NavigationMenuList>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { NavigationMenuTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronDown } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { navigationMenuTriggerStyle } from "."
const props = defineProps<NavigationMenuTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<NavigationMenuTrigger
data-slot="navigation-menu-trigger"
v-bind="forwardedProps"
:class="cn(navigationMenuTriggerStyle(), 'group', props.class)"
>
<slot />
<ChevronDown
class="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NavigationMenuViewportProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuViewport,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuViewportProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
<NavigationMenuViewport
data-slot="navigation-menu-viewport"
v-bind="forwardedProps"
:class="
cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-(--reka-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border shadow md:w-(--reka-navigation-menu-viewport-width) left-(--reka-navigation-menu-viewport-left)',
props.class,
)
"
/>
</div>
</template>

View file

@ -0,0 +1,14 @@
import { cva } from "class-variance-authority"
export { default as NavigationMenu } from "./NavigationMenu.vue"
export { default as NavigationMenuContent } from "./NavigationMenuContent.vue"
export { default as NavigationMenuIndicator } from "./NavigationMenuIndicator.vue"
export { default as NavigationMenuItem } from "./NavigationMenuItem.vue"
export { default as NavigationMenuLink } from "./NavigationMenuLink.vue"
export { default as NavigationMenuList } from "./NavigationMenuList.vue"
export { default as NavigationMenuTrigger } from "./NavigationMenuTrigger.vue"
export { default as NavigationMenuViewport } from "./NavigationMenuViewport.vue"
export const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1",
)

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="sheet"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="sheet-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { X } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "side")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="sheet-footer"
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="sheet-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View file

@ -0,0 +1,8 @@
export { default as Sheet } from "./Sheet.vue"
export { default as SheetClose } from "./SheetClose.vue"
export { default as SheetContent } from "./SheetContent.vue"
export { default as SheetDescription } from "./SheetDescription.vue"
export { default as SheetFooter } from "./SheetFooter.vue"
export { default as SheetHeader } from "./SheetHeader.vue"
export { default as SheetTitle } from "./SheetTitle.vue"
export { default as SheetTrigger } from "./SheetTrigger.vue"

7
src/lib/utils.ts Normal file
View file

@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

11
src/main.ts Normal file
View file

@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'
import "@/assets/index.css"
const app = createApp(App)
app.use(router)
app.mount('#app')

21
src/router/index.ts Normal file
View file

@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path:"/",
component: () => import("@/views/IndexView.vue")
},
{
path:"/skills",
component: () => import("@/views/SkillsView.vue")
},
{
path:"/work",
component: () => import("@/views/WorkView.vue")
}
],
})
export default router

5
src/types/routes.ts Normal file
View file

@ -0,0 +1,5 @@
export enum Routes {
Home = "/",
Skills = "/skills",
Work = "/work",
}

4
src/types/theme.ts Normal file
View file

@ -0,0 +1,4 @@
export enum Theme {
Light,
Dark
}

11
src/views/IndexView.vue Normal file
View file

@ -0,0 +1,11 @@
<template>
<div class="flex flex-col gap-2 w-full mb-4">
<h1 class="text-4xl md:text-5xl italic font-bold ">Hey, my name is Matthew McPeak!</h1>
<h2 class="text-xl italic text-muted-foreground">I write code that holds up: reliable, maintainable, and built to last under real constraints.</h2>
<p>Based in Kingsport, TN. I'm deliberate by nature. I'd rather spend the time to get something right than ship something fast that someone else has to fix later. Six years building SaaS for county clerks across Tennessee will do that: a small user base with a wide blast radius, traffic that spikes hard around filing deadlines, and real consequences for cutting corners. The work I'm proudest of started that way. I hand-built a prototype workflow engine to solve a problem I kept hitting, it worked well enough that our VP of Software rebuilt it for production, and it now backs most of the workflows in the product.</p>
</div>
<div class="flex flex-col md:flex-row mt-4 gap-10 w-full justify-evenly text-center" >
<h2 class="text-xl italic text-muted-foreground"><span class="font-bold text-primary-foreground bg-primary rounded-lg p-2">6+</span> Years of Experience</h2>
<h2 class="text-xl italic text-muted-foreground"><span class="font-bold text-primary-foreground bg-primary rounded-lg p-2">10+</span> Workflows on an engine I designed</h2>
</div>
</template>

68
src/views/SkillsView.vue Normal file
View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Badge
} from '@/components/ui/badge'
</script>
<template>
<div class="flex flex-col gap-2 w-full mb-4">
<h1 class="text-5xl italic font-bold ">Skills</h1>
</div>
<div class="flex flex-col md:flex-row gap-2 w-full">
<Card class="w-full">
<CardHeader class="pb-2 border-b-2">
<CardTitle>Front-End</CardTitle>
</CardHeader>
<CardContent class="flex flex-row gap-2 w-full">
<Badge>TypeScript</Badge>
<Badge>Vue 3</Badge>
<Badge>Nuxt 3</Badge>
<Badge>shadcn</Badge>
<Badge>Tailwind</Badge>
</CardContent>
</Card>
<Card class="w-full">
<CardHeader class="pb-2 border-b-2">
<CardTitle>Back-End</CardTitle>
</CardHeader>
<CardContent class="flex flex-row gap-2 w-full">
<Badge>Go</Badge>
<Badge>MySQL</Badge>
<Badge>REST APIs</Badge>
<Badge>C#</Badge>
<Badge>ASP.NET Web API</Badge>
</CardContent>
</Card>
</div>
<div class="flex flex-col md:flex-row gap-2 w-full">
<Card class="w-full">
<CardHeader class="pb-2 border-b-2">
<CardTitle>Practices</CardTitle>
</CardHeader>
<CardContent class="flex flex-row gap-2 w-full">
<Badge>WCAG</Badge>
<Badge>Component architecture</Badge>
<Badge>Agentic workflows</Badge>
</CardContent>
</Card>
<Card class="w-full">
<CardHeader class="pb-2 border-b-2">
<CardTitle>Tools</CardTitle>
</CardHeader>
<CardContent class="flex flex-row gap-2 w-full">
<Badge>Git</Badge>
<Badge>Vite</Badge>
<Badge>Docker</Badge>
<Badge>Claude Code</Badge>
<Badge>Cursor</Badge>
</CardContent>
</Card>
</div>
</template>

56
src/views/WorkView.vue Normal file
View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import {
Card,
CardDescription,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Badge
} from '@/components/ui/badge'
import { Minus } from '@lucide/vue';
</script>
<template>
<div class="flex flex-col gap-2 w-full mb-4">
<h1 class="text-5xl italic font-bold ">Work History</h1>
</div>
<div class="flex flex-col md:flex-row gap-2 w-full">
<Card class="w-full">
<CardHeader class="pb-2 border-b-2">
<CardTitle>Software Developer - i3 Verticals</CardTitle>
<CardDescription class="italic">Jun 2020 - Present</CardDescription>
<CardDescription class="italic">Government SaaS for county clerks across Tennessee. Vue 3 and TypeScript front ends against REST microservices.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-2 w-full">
<div class="flex flex-col gap-2">
<Badge class="text-md">Workflow Engine</Badge>
<div class="flex flex-row gap-2 pl-4">
<span>
<Minus :size="16" class="basis-1/12 pt-1" />
</span>
<p class="italic text-sm">Designed it and hand-built the prototype that proved the approach. Our VP of Software rebuilt it for production in Go, and it now backs 10+ distinct workflows across the product, areas that share nothing except being workflows. I ran one of the migrations onto it solo, over several weeks, in a live system. I'm still the person called in when it misbehaves.</p>
</div>
<Badge class="text-md">Self-Service Kiosks</Badge>
<div class="flex flex-row gap-2 pl-4">
<span>
<Minus :size="16" class="basis-1/12 pt-1" />
</span>
<p class="italic text-sm">Improved the UI and the device communications layer on kiosks built to handle new vehicle plate requirements. Talking to physical hardware from a web stack brings a different set of failure modes than a browser: timeouts, partial reads, and no user standing there to retry.</p>
</div>
<Badge class="text-md">Reporting</Badge>
<div class="flex flex-row gap-2 pl-4">
<span>
<Minus :size="16" class="basis-1/12 pt-1" />
</span>
<p class="italic text-sm">Added PDF, CSV, Excel and HTML output formats to the reporting engine, then wrote a Claude Code skill so any of our 4 to 8 developers can generate new reports without me in the loop.</p>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

18
tsconfig.app.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

28
tsconfig.node.json Normal file
View file

@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

18
vite.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})