Compare commits

...

5 Commits

Author SHA1 Message Date
570a823426 Improve responsive layout for Home sidebar and utility components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m50s
Switch sidebar to CSS grid, constrain images on mobile, add max-height to Chat, and improve Radio/Time/Timer compact styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:37 +00:00
6dddcd4d7a Replace raw anchor tags with Link component across views
Use Link component in Chat, CommitHistory, Stamps, Demoman, and fix Navbar to use span instead of nested anchors. Also updates Navbar inHome check for /stp route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:32 +00:00
69e158b871 Add Landing page and move Home to /stp route
New professional landing page at / with bio, about section, and nav links. Previous home page now lives at /stp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:27 +00:00
d857cce5dc Consolidate OptionalLinkTable and ToggleLinkTable into LinkTable
LinkTable now supports variant (list/table) and optional title toggle, replacing the need for separate components. Updates all consumers to use the unified API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:24 +00:00
c2bbd7ad88 Add Link and InlineLink reusable components
Link wraps RouterLink or <a> with consistent styling and automatic rel attributes. InlineLink adds bold italic inline link styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:12 +00:00
21 changed files with 284 additions and 103 deletions

View File

@@ -16,7 +16,7 @@ const parentPath = computed(() => {
});
const inHome = computed(() => {
return route.path == "/";
return route.path == "/" || route.path == "/stp";
});
const faces = [
@@ -47,10 +47,10 @@ const faces_string = faces.join(" ");
<template>
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
<a>HOME</a>
<span>HOME</span>
</RouterLink>
<RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
<a>UP</a>
<span>UP</span>
</RouterLink>
<Headline class="border flex-1 max-w-full">
<code class="whitespace-pre">{{ faces_string }}</code>

View File

@@ -0,0 +1,39 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
href: { type: String, default: "" },
to: { type: String, default: "" },
target: { type: String, default: undefined },
rel: { type: String, default: undefined },
});
const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer";
return undefined;
});
</script>
<template>
<RouterLink v-if="to" :to="to" class="inline-link">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link">
<slot />
</a>
</template>
<style scoped>
.inline-link {
color: var(--primary);
font-weight: bold;
font-style: italic;
text-decoration: none;
transition: color 0.15s ease;
}
.inline-link:hover {
color: var(--tertiary);
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
href: { type: String, default: "" },
to: { type: String, default: "" },
target: { type: String, default: undefined },
rel: { type: String, default: undefined },
bare: { type: Boolean, default: false },
});
const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer";
return undefined;
});
</script>
<template>
<RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }">
<slot />
</a>
</template>
<style scoped>
.link {
color: var(--primary);
text-decoration: none;
transition: color 0.15s ease;
}
.link:hover {
color: var(--tertiary);
}
</style>

View File

@@ -4,6 +4,7 @@ import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
import Link from "@/components/text/Link.vue";
const messagesStore = useMessagesStore();
const authStore = useAuthStore();
@@ -79,7 +80,7 @@ onUnmounted(() => {
</script>
<template>
<div class="flex-col flex min-h-0">
<div class="chat-root flex-col flex min-h-0">
<Header>Chat</Header>
<div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0">
<p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full">
@@ -88,13 +89,13 @@ onUnmounted(() => {
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<a
<Link
v-if="part.type === 'link'"
bare
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline break-all"
>{{ part.value }}</a
>{{ part.value }}</Link
>
<span v-else>{{ part.value }}</span>
</template>
@@ -112,9 +113,9 @@ onUnmounted(() => {
class="w-full max-w-full max-h-48 rounded block"
@loadedmetadata="scrollToBottom"
/>
<a v-else :href="message.fileUrl" target="_blank" class="underline break-all">{{
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
message.fileUrl.split("/").pop()
}}</a>
}}</Link>
</template>
</p>
</div>
@@ -139,3 +140,11 @@ onUnmounted(() => {
</div>
</div>
</template>
<style scoped>
@media (max-width: 850px) {
.chat-root {
max-height: 400px;
}
}
</style>

View File

@@ -2,6 +2,7 @@
import { useHomeDataStore } from "@/stores/homeData";
import { storeToRefs } from "pinia";
import Header from "@/components/text/Header.vue";
import Link from "@/components/text/Link.vue";
const homeData = useHomeDataStore();
const { gitFeed: feed, loaded } = storeToRefs(homeData);
@@ -18,9 +19,9 @@ const { gitFeed: feed, loaded } = storeToRefs(homeData);
<div v-else-if="feed" class="flex-1 flex flex-col overflow-y-auto">
<h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" />
<a :href="feed.repoUrl">
<Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3>
</a>
</Link>
<p>Action: {{ feed.opType }}</p>
<p>Message: {{ feed.commitMessage }}</p>
<small> {{ new Date(feed.createdAt).toLocaleString() }}</small>

View File

@@ -1,20 +1,73 @@
<script setup>
import { computed } from "vue";
import { ref } from "vue";
import Link from "@/components/text/Link.vue";
import ToggleHeader from "@/components/text/ToggleHeader.vue";
const props = defineProps({
linkArr: {
items: {
type: Array,
required: true,
},
variant: {
type: String,
default: "list",
},
title: {
type: String,
default: "",
},
});
const keys = ["name", "link"];
const show = ref(false);
</script>
<template>
<a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link">
<p class="bdr-2 bg-bg_tertiary">
{{ row.name }}
</p>
</a>
<div v-if="title" class="h-fit w-fit">
<ToggleHeader v-model="show" class="justify-between flex">
{{ title }}
</ToggleHeader>
<template v-if="show">
<Link
v-if="variant === 'list'"
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
<table v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>
<table v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</template>

View File

@@ -1,27 +0,0 @@
<script setup>
// Array will have the form
// [ {type: string, name: string, link?: string}]
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const keys = ["type", "name", "link"];
</script>
<template>
<table>
<tbody>
<tr v-for="item in data" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<a :href="item.link">{{ item.name }}</a>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>

View File

@@ -46,3 +46,11 @@ onMounted(() => {
setInterval(checkStream, 120000);
});
</script>
<style scoped>
img {
width: 100%;
max-height: 150px;
object-fit: cover;
}
</style>

View File

@@ -29,3 +29,10 @@ setInterval(updateDateTime, 60000);
<h1>{{ time }}</h1>
</div>
</template>
<style scoped>
div {
text-align: center;
padding: 4px;
}
</style>

View File

@@ -65,7 +65,7 @@ function playFinishedSound() {
</script>
<template>
<div class="flex flex-col gap-1 p-1 items-center">
<div class="timer-root flex flex-col gap-1 p-1 items-center">
<Header>Timer</Header>
<div v-if="finished && paused" class="flex flex-col">
<div class="flex flex-row p-2 place-content-around">
@@ -113,3 +113,12 @@ function playFinishedSound() {
</div>
</div>
</template>
<style scoped>
@media (max-width: 850px) {
.timer-root {
padding: 2px;
gap: 2px;
}
}
</style>

View File

@@ -1,28 +0,0 @@
<script setup>
import ToggleHeader from "@/components/text/ToggleHeader.vue";
import { ref } from "vue";
import LinkTable from "@/components/util/LinkTable.vue";
const props = defineProps({
linkArr: {
type: Array,
required: true,
},
title: {
type: String,
required: true,
},
});
const show_links = ref(false);
</script>
<template>
<div class="h-fit w-fit">
<ToggleHeader v-model="show_links" class="justify-between flex"
>{{ title }}
</ToggleHeader>
<LinkTable v-if="show_links" :linkArr="props.linkArr" />
</div>
</template>

View File

@@ -1,13 +1,18 @@
import { createRouter, createWebHistory } from "vue-router";
import Home from "@/views/home/Home.vue";
import Landing from "@/views/Landing.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "landing",
component: Landing,
},
{
path: "/stp",
name: "home",
component: Home,
component: () => import("@/views/home/Home.vue"),
},
{
path: "/cv",

View File

@@ -1,7 +1,5 @@
<script setup>
import { ref } from "vue";
import ToggleLinkTable from "@/components/util/ToggleLinkTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
const links = [
[
@@ -245,11 +243,11 @@ const links = [
class="a4page-portrait bdr-1 flex flex-row flex-wrap overflow-x-auto gap-1"
>
<div class="w-full h-fit">
<ToggleLinkTable
<LinkTable
class="flex flex-col flex-wrap"
v-for="link in links"
:title="link[0]"
:linkArr="link[1]"
:items="link[1]"
/>
</div>
</div>

View File

@@ -0,0 +1,56 @@
<script setup>
import Link from "@/components/text/Link.vue";
import InlineLink from "@/components/text/InlineLink.vue";
import Header from "@/components/text/Header.vue";
import Paragraph from "@/components/text/Paragraph.vue";
const links = [
{ name: "GitHub", href: "https://github.com/SteveThePug" },
{ name: "Gitea", href: "/gitea/explore/repos" },
{ name: "Spotify", href: "https://open.spotify.com/user/stevethepug" },
];
</script>
<template>
<main class="halftone flex justify-center px-4 py-16">
<div class="max-w-xl w-full flex flex-col gap-12">
<section>
<Header>Adam French</Header>
<Paragraph>
Junior software engineer focused on full-stack development,
systems programming, and infrastructure. First Class Honours
in Computer Science with Mathematics from Leeds and
Waterloo.
</Paragraph>
</section>
<section>
<Header>About</Header>
<Paragraph>
This website is self-hosted and has a lot more on it than it
needs to. Please have a look at my
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of
my experience, projects, and skills. Please visit
<InlineLink to="/stp">STP</InlineLink> for the prefered but
less professional experience.
</Paragraph>
</section>
<nav class="navRow flex flex-row flex-wrap gap-4 justify-around">
<Link to="/cv"> CV </Link>
<Link to="/stp"> STP </Link>
<Link href="mailto:adam.a.french@outlook.com"> Email </Link>
<Link v-for="link in links" :key="link.name" :href="link.href">
{{ link.name }}
</Link>
</nav>
</div>
</main>
</template>
<style scoped>
.navRow > a {
padding: 0.5rem 1rem;
border: 1px solid currentColor;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup>
import AutoScroll from "@/components/util/AutoScroll.vue";
import OptionalLinkTable from "@/components/util/OptionalLinkTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
import Header from "@/components/text/Header.vue";
import { useActivityStore } from "@/stores/activity";
@@ -12,7 +12,7 @@ const activityStore = useActivityStore();
<div class="flex flex-col items-center">
<Header>Consumption</Header>
<AutoScroll class="flex-1 w-full">
<OptionalLinkTable class="w-full" :data="activityStore.activity" />
<LinkTable variant="table" class="w-full" :items="activityStore.activity" />
</AutoScroll>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup>
import Header from "@/components/text/Header.vue";
import OptionalLinkTable from "@/components/util/OptionalLinkTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
import AutoScroll from "@/components/util/AutoScroll.vue";
import { useFavoritesStore } from "@/stores/favorites";
@@ -12,9 +12,10 @@ const favoritesStore = useFavoritesStore();
<div class="flex flex-col items-center">
<Header>favs</Header>
<AutoScroll class="w-full flex-1">
<OptionalLinkTable
<LinkTable
variant="table"
class="w-full"
:data="favoritesStore.favorites"
:items="favoritesStore.favorites"
/>
</AutoScroll>
</div>

View File

@@ -1,6 +1,6 @@
<script setup>
import Header from "@/components/text/Header.vue";
import OptionalLinkTable from "@/components/util/OptionalLinkTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
const gym = [
{ name: "Row", type: "30 min" },
{ name: "Run", type: "5k" },
@@ -14,7 +14,7 @@ const gym = [
<p>I'm not a gym geek</p>
<p>4/7 days I do:</p>
<div class="overflow-scroll w-full border-box">
<OptionalLinkTable class="w-full" :data="gym" />
<LinkTable variant="table" class="w-full" :items="gym" />
</div>
</div>
</template>

View File

@@ -31,7 +31,7 @@ import Consumption from "./Consumption.vue";
>
<Chat class="flex-1 min-h-0" />
</div>
<div>
<div class="sidebar-image">
<Miku class="border-tertiary border bg-bg_secondary" />
</div>
</div>
@@ -63,7 +63,7 @@ import Consumption from "./Consumption.vue";
<!-- <Elle class="flex-1" /> -->
<!-- <MusicPlayer /> -->
</div>
<div>
<div class="sidebar-image">
<img
src="/img/memes/fire-woman.gif"
class="border-tertiary border"
@@ -122,14 +122,24 @@ import Consumption from "./Consumption.vue";
}
.sidebar {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 8px;
justify-items: stretch;
}
.sidebar > * {
max-width: 400px;
width: 100%;
max-width: none;
width: auto;
}
.sidebar-image {
max-height: 200px;
}
.sidebar-image :deep(img) {
max-height: 200px;
object-fit: contain;
}
}

View File

@@ -24,7 +24,7 @@ const social_links = [
<RouterTable :linkArr="site_links" />
</div>
<div class="flex flex-col gap-1">
<LinkTable :linkArr="social_links" />
<LinkTable :items="social_links" />
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref } from "vue";
import Touchscreen from "@/components/util/Touchscreen.vue";
import Link from "@/components/text/Link.vue";
import { shuffleArray } from "@/js/utils.js";
let srcs = [
@@ -23,15 +24,15 @@ shuffleArray(srcs);
<template>
<Touchscreen>
<div class="flex flex-wrap tst">
<a href="https://www.adam-french.co.uk">
<Link bare href="https://www.adam-french.co.uk">
<img src="https://www.adam-french.co.uk/img/stamps/mine.gif" />
</a>
<a href="https://jacobbarron.xyz">
</Link>
<Link bare href="https://jacobbarron.xyz">
<img
src="https://jacobbarron.xyz/Banneh.gif"
alt="jacobbarron.xyz"
/>
</a>
</Link>
<img v-for="src in srcs" :src="src" />
</div>
</Touchscreen>

View File

@@ -1,5 +1,6 @@
<script setup>
import VideoTable from "@/components/util/VideoTable.vue";
import Link from "@/components/text/Link.vue";
const videoSources = [
{ name: "demoman", link: "/img/demoman/1760582395316219.webm" },
@@ -16,8 +17,8 @@ const videoSources = [
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
>
<p>
<a href="https://wiki.teamfortress.com/wiki/Demoman"
>The goat</a
<Link href="https://wiki.teamfortress.com/wiki/Demoman"
>The goat</Link
>
</p>
<div>