Big formatting spree
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s

This commit is contained in:
2026-04-29 09:06:41 +01:00
parent b41e67fe1a
commit 3844a32751
76 changed files with 3146 additions and 2788 deletions

View File

@@ -6,10 +6,13 @@ networks:
- subnet: 172.28.0.0/16
volumes:
# Postgres database
dbdata:
# File upload
uploads:
# Vue build
vue_dist:
# Searxng data
searxng_data:
services:

View File

@@ -1,7 +1,7 @@
#!/bin/sh
set -e
# Check if dev mode, certificate exists, or setup mode
# Check if DEV_MODE
if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Generating self-signed certificate for HTTPS."
CERT_DIR="/etc/letsencrypt/live/localhost"
@@ -12,16 +12,19 @@ if [ "$DEV_MODE" = "true" ]; then
-out "$CERT_DIR/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null
fi
# In dev mode, so use nginx_dev.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
</etc/nginx/nginx_dev.conf.template \
>/etc/nginx/nginx.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config."
# In production with certificates already existing, so use nginx.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
</etc/nginx/nginx.conf.template \
>/etc/nginx/nginx.conf
else
echo "Certificates NOT found. Using setup nginx config."
# In production with no certificates, so use nginx_setup.conf.template and will need restart after generation
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
fi

View File

@@ -1,31 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Adam French's personal website">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Adam French's personal website" />
<title>AF</title>
<link rel="preconnect" href="https://i.scdn.co" crossorigin>
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
<link
rel="preconnect"
href="https://cdn.akamai.steamstatic.com"
crossorigin
>
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico">
/>
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
<link
rel="preload"
href="/fonts/big_noodle_titling.woff2"
as="font"
type="font/woff2"
crossorigin
>
/>
<link
rel="preload"
href="/fonts/CreatoDisplay-Bold.woff2"
as="font"
type="font/woff2"
crossorigin
>
/>
</head>
<body id="app">
<script type="module" src="/src/main.js"></script>

View File

@@ -19,7 +19,13 @@ const computedRel = computed(() => {
<RouterLink v-if="to" :to="to" class="inline-link">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link">
<a
v-else
:href="href"
:target="target"
:rel="computedRel"
class="inline-link"
>
<slot />
</a>
</template>

View File

@@ -20,7 +20,13 @@ const computedRel = computed(() => {
<RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }">
<a
v-else
:href="href"
:target="target"
:rel="computedRel"
:class="{ link: !bare }"
>
<slot />
</a>
</template>

View File

@@ -1,5 +1,10 @@
<template>
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto">
<div
ref="container"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="overflow-y-auto"
>
<slot />
</div>
</template>

View File

@@ -20,8 +20,7 @@ let resizeObserver = null;
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}
@@ -146,9 +145,7 @@ onUnmounted(() => {
>
<span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(
message.text || '',
)"
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<Link
@@ -161,9 +158,7 @@ onUnmounted(() => {
>
<span v-else>{{ part.value }}</span>
</template>
<template
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"

View File

@@ -21,7 +21,12 @@ const { gitFeed: feed, loaded } = storeToRefs(homeData);
class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" loading="lazy" />
<img
:src="feed.avatarUrl"
alt="User avatar"
class="avatar"
loading="lazy"
/>
<Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3>
</Link>

View File

@@ -50,11 +50,7 @@ const show = ref(false);
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<Link v-for="(item, i) in items" :key="i" :href="item.link">
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>

View File

@@ -1,13 +1,12 @@
<script setup>
import Button from "@/components/input/Button.vue";
</script>
<template>
<audio />
<div class="musicPlayerGrid">
<div class="album_cover">
<img src="/img/Untitled.png" alt=""></img>
<img src="/img/Untitled.png" alt="" />
</div>
<div class="controls">
<div class="sliders">

View File

@@ -2,6 +2,7 @@ import axios from "axios";
export async function gql(query, variables = {}) {
const res = await axios.post("/api/graphql", { query, variables });
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message);
if (res.data.errors && !res.data.data)
throw new Error(res.data.errors[0].message);
return res.data.data;
}

View File

@@ -17,27 +17,55 @@ import { RouterView } from "vue-router";
</style>
<style>
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4,
.cv-layout p, .cv-layout small, .cv-layout code, .cv-layout ul, .cv-layout li,
.cv-layout td, .cv-layout tr, .cv-layout table {
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4,
.cv-layout p,
.cv-layout small,
.cv-layout code,
.cv-layout ul,
.cv-layout li,
.cv-layout td,
.cv-layout tr,
.cv-layout table {
color: #111;
}
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4 { margin: 0; }
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4 {
margin: 0;
}
.cv-layout a {
color: #111;
background-color: transparent !important;
letter-spacing: normal;
}
.cv-layout input, .cv-layout textarea {
.cv-layout input,
.cv-layout textarea {
color: #111;
background-color: white;
border: 1px solid #ccc;
padding: 0;
width: auto;
}
.cv-layout input::placeholder, .cv-layout textarea::placeholder { color: #999; opacity: 1; }
.cv-layout table { border: 0 solid transparent; }
.cv-layout tr { border-color: transparent; }
.cv-layout th { border: none; padding: 0; }
.cv-layout td { padding: 0; }
.cv-layout input::placeholder,
.cv-layout textarea::placeholder {
color: #999;
opacity: 1;
}
.cv-layout table {
border: 0 solid transparent;
}
.cv-layout tr {
border-color: transparent;
}
.cv-layout th {
border: none;
padding: 0;
}
.cv-layout td {
padding: 0;
}
</style>

View File

@@ -91,12 +91,19 @@ router.beforeEach(async (to) => {
const homeData = useHomeDataStore();
if (!homeData.loaded) {
await new Promise((resolve) => {
const stop = watch(() => homeData.loaded, (val) => {
if (val) { stop(); resolve(); }
});
const stop = watch(
() => homeData.loaded,
(val) => {
if (val) {
stop();
resolve();
}
},
);
});
}
if (!useAuthStore().user.admin) return { path: "/admin/login", query: { redirect: to.fullPath } };
if (!useAuthStore().user.admin)
return { path: "/admin/login", query: { redirect: to.fullPath } };
});
export default router;

View File

@@ -23,7 +23,14 @@ const editRefForm = ref({});
const REF_CATEGORIES = ["profile", "experience"];
const REF_FIELDS = `id category label value sortOrder createdAt`;
const STATUS_OPTIONS = ["Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn"];
const STATUS_OPTIONS = [
"Applied",
"Screening",
"Interview",
"Offer",
"Rejected",
"Withdrawn",
];
const APP_FIELDS = `id jobTitle company location url status notes appliedAt createdAt`;
@@ -46,7 +53,9 @@ async function createApplication() {
location: form.value.location || undefined,
url: form.value.url || undefined,
notes: form.value.notes || undefined,
appliedAt: form.value.appliedAt ? new Date(form.value.appliedAt).toISOString() : undefined,
appliedAt: form.value.appliedAt
? new Date(form.value.appliedAt).toISOString()
: undefined,
};
const data = await gql(
`mutation CreateJobApplication($input: CreateJobApplicationInput!) {
@@ -55,7 +64,15 @@ async function createApplication() {
{ input },
);
applications.value.unshift(data.createJobApplication);
form.value = { jobTitle: "", company: "", location: "", url: "", status: "Applied", notes: "", appliedAt: "" };
form.value = {
jobTitle: "",
company: "",
location: "",
url: "",
status: "Applied",
notes: "",
appliedAt: "",
};
} catch (err) {
console.error(err);
}
@@ -88,7 +105,9 @@ async function saveEdit(id) {
location: editForm.value.location || undefined,
url: editForm.value.url || undefined,
notes: editForm.value.notes || undefined,
appliedAt: editForm.value.appliedAt ? new Date(editForm.value.appliedAt).toISOString() : undefined,
appliedAt: editForm.value.appliedAt
? new Date(editForm.value.appliedAt).toISOString()
: undefined,
};
const data = await gql(
`mutation UpdateJobApplication($id: ID!, $input: UpdateJobApplicationInput!) {
@@ -117,7 +136,16 @@ async function deleteApplication(id) {
}
function exportCsv() {
const headers = ["Job Title", "Company", "Status", "Location", "URL", "Applied", "Notes", "Created"];
const headers = [
"Job Title",
"Company",
"Status",
"Location",
"URL",
"Applied",
"Notes",
"Created",
];
const rows = applications.value.map((a) => [
a.jobTitle,
a.company,
@@ -171,7 +199,11 @@ async function createReference() {
function startRefEdit(ref) {
editingRefId.value = ref.id;
editRefForm.value = { category: ref.category, label: ref.label, value: ref.value };
editRefForm.value = {
category: ref.category,
label: ref.label,
value: ref.value,
};
}
function cancelRefEdit() {
@@ -245,58 +277,140 @@ onMounted(() => {
<RouterLink to="/cv" class="ja-back"> CV</RouterLink>
<h2 class="ja-heading">Job Applications</h2>
</div>
<button class="ja-btn" @click="exportCsv" :disabled="!applications.length">Export CSV</button>
<button
class="ja-btn"
@click="exportCsv"
:disabled="!applications.length"
>
Export CSV
</button>
</div>
<div class="ja-ref-section">
<h3 class="ja-ref-heading">Quick Reference</h3>
<div v-for="cat in REF_CATEGORIES" :key="cat" class="ja-ref-category">
<h4 class="ja-ref-cat-label">{{ cat }}</h4>
<div v-for="ref in refsByCategory(cat)" :key="ref.id" class="ja-ref-item">
<div
v-for="ref in refsByCategory(cat)"
:key="ref.id"
class="ja-ref-item"
>
<template v-if="editingRefId !== ref.id">
<span class="ja-ref-label">{{ ref.label }}</span>
<span class="ja-ref-value" :title="ref.value">{{ ref.value }}</span>
<button class="ja-btn ja-btn-sm" @click="copyToClipboard(ref.value)" title="Copy">Copy</button>
<button class="ja-btn ja-btn-sm" @click="startRefEdit(ref)">Edit</button>
<button class="ja-btn ja-btn-sm ja-btn-danger" @click="deleteReference(ref.id)">Delete</button>
<button
class="ja-btn ja-btn-sm"
@click="copyToClipboard(ref.value)"
title="Copy"
>
Copy
</button>
<button class="ja-btn ja-btn-sm" @click="startRefEdit(ref)">
Edit
</button>
<button
class="ja-btn ja-btn-sm ja-btn-danger"
@click="deleteReference(ref.id)"
>
Delete
</button>
</template>
<template v-else>
<select v-model="editRefForm.category" class="ja-input ja-input-sm ja-select">
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
<select
v-model="editRefForm.category"
class="ja-input ja-input-sm ja-select"
>
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">
{{ c }}
</option>
</select>
<input v-model="editRefForm.label" class="ja-input ja-input-sm" placeholder="Label" />
<input v-model="editRefForm.value" class="ja-input ja-input-sm" placeholder="Value" />
<button class="ja-btn ja-btn-sm ja-btn-primary" @click="saveRefEdit(ref.id)">Save</button>
<button class="ja-btn ja-btn-sm" @click="cancelRefEdit">Cancel</button>
<input
v-model="editRefForm.label"
class="ja-input ja-input-sm"
placeholder="Label"
/>
<input
v-model="editRefForm.value"
class="ja-input ja-input-sm"
placeholder="Value"
/>
<button
class="ja-btn ja-btn-sm ja-btn-primary"
@click="saveRefEdit(ref.id)"
>
Save
</button>
<button class="ja-btn ja-btn-sm" @click="cancelRefEdit">
Cancel
</button>
</template>
</div>
<p v-if="!refsByCategory(cat).length" class="ja-ref-empty">No {{ cat }} items yet.</p>
<p v-if="!refsByCategory(cat).length" class="ja-ref-empty">
No {{ cat }} items yet.
</p>
</div>
<form class="ja-ref-form" @submit.prevent="createReference">
<select v-model="refForm.category" class="ja-input ja-select">
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">
{{ c }}
</option>
</select>
<input v-model="refForm.label" class="ja-input" placeholder="Label *" required />
<input v-model="refForm.value" class="ja-input" placeholder="Value *" required />
<input
v-model="refForm.label"
class="ja-input"
placeholder="Label *"
required
/>
<input
v-model="refForm.value"
class="ja-input"
placeholder="Value *"
required
/>
<button type="submit" class="ja-btn ja-btn-primary">Add</button>
</form>
</div>
<form class="ja-form" @submit.prevent="createApplication">
<div class="ja-form-row">
<input v-model="form.jobTitle" class="ja-input" placeholder="Job title *" required />
<input v-model="form.company" class="ja-input" placeholder="Company *" required />
<input
v-model="form.jobTitle"
class="ja-input"
placeholder="Job title *"
required
/>
<input
v-model="form.company"
class="ja-input"
placeholder="Company *"
required
/>
<select v-model="form.status" class="ja-input ja-select">
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ s }}</option>
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">
{{ s }}
</option>
</select>
</div>
<div class="ja-form-row">
<input v-model="form.location" class="ja-input" placeholder="Location" />
<input
v-model="form.location"
class="ja-input"
placeholder="Location"
/>
<input v-model="form.url" class="ja-input" placeholder="URL" />
<input v-model="form.appliedAt" class="ja-input" type="date" title="Applied date" />
<input
v-model="form.appliedAt"
class="ja-input"
type="date"
title="Applied date"
/>
</div>
<div class="ja-form-row">
<textarea v-model="form.notes" class="ja-input ja-textarea" placeholder="Notes" />
<textarea
v-model="form.notes"
class="ja-input ja-textarea"
placeholder="Notes"
/>
<button type="submit" class="ja-btn ja-btn-primary">Add</button>
</div>
</form>
@@ -317,43 +431,93 @@ onMounted(() => {
<template v-for="app in applications" :key="app.id">
<tr v-if="editingId !== app.id">
<td>
<a v-if="app.url" :href="app.url" target="_blank" rel="noopener" class="ja-link">{{ app.jobTitle }}</a>
<a
v-if="app.url"
:href="app.url"
target="_blank"
rel="noopener"
class="ja-link"
>{{ app.jobTitle }}</a
>
<span v-else>{{ app.jobTitle }}</span>
</td>
<td>{{ app.company }}</td>
<td><span :class="['ja-badge', statusClass(app.status)]">{{ app.status }}</span></td>
<td>
<span :class="['ja-badge', statusClass(app.status)]">{{
app.status
}}</span>
</td>
<td>{{ app.location ?? "—" }}</td>
<td>{{ app.appliedAt ? app.appliedAt.substring(0, 10) : "—" }}</td>
<td class="ja-notes-cell">{{ app.notes ?? "" }}</td>
<td class="ja-actions">
<button class="ja-btn ja-btn-sm" @click="startEdit(app)">Edit</button>
<button class="ja-btn ja-btn-sm ja-btn-danger" @click="deleteApplication(app.id)">Delete</button>
<button class="ja-btn ja-btn-sm" @click="startEdit(app)">
Edit
</button>
<button
class="ja-btn ja-btn-sm ja-btn-danger"
@click="deleteApplication(app.id)"
>
Delete
</button>
</td>
</tr>
<tr v-else class="ja-edit-row">
<td>
<input v-model="editForm.jobTitle" class="ja-input ja-input-sm" placeholder="Job title" />
<input
v-model="editForm.jobTitle"
class="ja-input ja-input-sm"
placeholder="Job title"
/>
</td>
<td>
<input v-model="editForm.company" class="ja-input ja-input-sm" placeholder="Company" />
<input
v-model="editForm.company"
class="ja-input ja-input-sm"
placeholder="Company"
/>
</td>
<td>
<select v-model="editForm.status" class="ja-input ja-input-sm ja-select">
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ s }}</option>
<select
v-model="editForm.status"
class="ja-input ja-input-sm ja-select"
>
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">
{{ s }}
</option>
</select>
</td>
<td>
<input v-model="editForm.location" class="ja-input ja-input-sm" placeholder="Location" />
<input
v-model="editForm.location"
class="ja-input ja-input-sm"
placeholder="Location"
/>
</td>
<td>
<input v-model="editForm.appliedAt" class="ja-input ja-input-sm" type="date" />
<input
v-model="editForm.appliedAt"
class="ja-input ja-input-sm"
type="date"
/>
</td>
<td>
<input v-model="editForm.notes" class="ja-input ja-input-sm" placeholder="Notes" />
<input
v-model="editForm.notes"
class="ja-input ja-input-sm"
placeholder="Notes"
/>
</td>
<td class="ja-actions">
<button class="ja-btn ja-btn-sm ja-btn-primary" @click="saveEdit(app.id)">Save</button>
<button class="ja-btn ja-btn-sm" @click="cancelEdit">Cancel</button>
<button
class="ja-btn ja-btn-sm ja-btn-primary"
@click="saveEdit(app.id)"
>
Save
</button>
<button class="ja-btn ja-btn-sm" @click="cancelEdit">
Cancel
</button>
</td>
</tr>
</template>
@@ -453,7 +617,9 @@ onMounted(() => {
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
transition:
background 0.15s,
color 0.15s;
}
.ja-btn:hover {
@@ -543,12 +709,30 @@ onMounted(() => {
color: #333;
}
.status-applied { background: #dbeafe; color: #1e40af; }
.status-screening { background: #fef9c3; color: #854d0e; }
.status-interview { background: #ede9fe; color: #5b21b6; }
.status-offer { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.status-withdrawn { background: #f3f4f6; color: #6b7280; }
.status-applied {
background: #dbeafe;
color: #1e40af;
}
.status-screening {
background: #fef9c3;
color: #854d0e;
}
.status-interview {
background: #ede9fe;
color: #5b21b6;
}
.status-offer {
background: #dcfce7;
color: #166534;
}
.status-rejected {
background: #fee2e2;
color: #991b1b;
}
.status-withdrawn {
background: #f3f4f6;
color: #6b7280;
}
.ja-empty {
color: #888;

View File

@@ -14,7 +14,13 @@ async function post() {
try {
const data = await gql(
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
{
input: {
type: type.value,
name: name.value,
link: link.value || undefined,
},
},
);
type.value = "";
name.value = "";

View File

@@ -13,7 +13,9 @@ async function submit() {
try {
await gql(
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
{ input: { category: category.value, name: name.value, link: link.value } },
{
input: { category: category.value, name: name.value, link: link.value },
},
);
category.value = "";
name.value = "";
@@ -29,8 +31,18 @@ async function submit() {
<div class="flex flex-col">
<h1>Create Bookmark</h1>
<input type="text" v-model="category" placeholder="Category" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="submit" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="submit" />
<input
type="text"
v-model="name"
placeholder="Name"
@keyup.enter="submit"
/>
<input
type="text"
v-model="link"
placeholder="Link"
@keyup.enter="submit"
/>
<Button @click="submit">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
</div>

View File

@@ -14,7 +14,13 @@ async function post() {
try {
const data = await gql(
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
{
input: {
type: type.value,
name: name.value,
link: link.value || undefined,
},
},
);
type.value = "";
name.value = "";

View File

@@ -27,12 +27,13 @@ async function post() {
<template>
<div class="flex flex-col">
<h1>Create Post</h1>
<input type="text" v-model="title" placeholder="Title" @keyup.enter="post" />
<textarea
class="h-50"
v-model="content"
placeholder="Content"
></textarea>
<input
type="text"
v-model="title"
placeholder="Title"
@keyup.enter="post"
/>
<textarea class="h-50" v-model="content" placeholder="Content"></textarea>
<Button @click="post">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
</div>

View File

@@ -15,7 +15,10 @@ function onFileChange(e) {
async function submit() {
if (!images.value.length) return;
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
results.value = images.value.map((f) => ({
name: f.name,
status: "Uploading...",
}));
await Promise.all(
images.value.map(async (file, i) => {
@@ -26,14 +29,17 @@ async function submit() {
headers: { "Content-Type": "multipart/form-data" },
});
const mins = Math.floor(res.data.Time / 1e9 / 60);
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(
2,
"0",
);
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
}),
);
images.value = [];
@@ -44,12 +50,19 @@ async function submit() {
<template>
<div class="flex flex-col gap-2">
<h1>Create Rowing</h1>
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
@change="onFileChange"
/>
<Button @click="submit">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{
r.status
}}</span>
</div>
</div>
</template>

View File

@@ -32,8 +32,18 @@ async function handleCreate() {
<h1>Create User</h1>
<p v-if="message" class="text-green-500">{{ message }}</p>
<p v-if="error" class="text-red-500">{{ error }}</p>
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleCreate" />
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleCreate" />
<input
type="text"
v-model="username"
placeholder="Username"
@keyup.enter="handleCreate"
/>
<input
type="password"
v-model="password"
placeholder="Password"
@keyup.enter="handleCreate"
/>
<Button @click="handleCreate">Create Account</Button>
</div>
<div v-else-if="auth.loggedIn" class="flex flex-col">

View File

@@ -33,8 +33,18 @@ function handleLogout() {
</div>
<div v-else class="flex flex-col">
<h1>Login</h1>
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleLogin" />
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" />
<input
type="text"
v-model="username"
placeholder="Username"
@keyup.enter="handleLogin"
/>
<input
type="password"
v-model="password"
placeholder="Password"
@keyup.enter="handleLogin"
/>
<Button @click="handleLogin">Log In</Button>
</div>
</template>

View File

@@ -25,7 +25,10 @@ function onFileChange(e) {
async function upload() {
if (!files.value.length) return;
loading.value = true;
results.value = files.value.map((f) => ({ name: f.name, status: "Uploading..." }));
results.value = files.value.map((f) => ({
name: f.name,
status: "Uploading...",
}));
await Promise.all(
files.value.map(async (file, i) => {
@@ -41,7 +44,7 @@ async function upload() {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
}),
);
files.value = [];
@@ -61,7 +64,9 @@ async function deleteSong(name) {
async function toggleSong(song) {
const action = song.disabled ? "enable" : "disable";
try {
await axios.patch(`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`);
await axios.patch(
`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`,
);
await fetchSongs();
} catch (err) {
console.error(err);
@@ -80,11 +85,18 @@ onMounted(fetchSongs);
<template>
<div class="flex flex-col gap-2">
<h1>Manage Radio</h1>
<input type="file" accept=".mp3,.ogg,.flac,.wav,.m4a,.opus" multiple @change="onFileChange" />
<input
type="file"
accept=".mp3,.ogg,.flac,.wav,.m4a,.opus"
multiple
@change="onFileChange"
/>
<Button @click="upload" :disabled="loading">Upload</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{
r.status
}}</span>
</div>
<div
v-for="song in songs"
@@ -95,7 +107,9 @@ onMounted(fetchSongs);
<span :class="{ 'line-through': song.disabled }">{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<span v-if="song.disabled" class="text-red-400 text-xs">disabled</span>
<Button @click="toggleSong(song)">{{ song.disabled ? "Enable" : "Disable" }}</Button>
<Button @click="toggleSong(song)">{{
song.disabled ? "Enable" : "Disable"
}}</Button>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
</div>

View File

@@ -31,13 +31,14 @@ onMounted(fetchUsers);
<template>
<div class="flex flex-col">
<h1>Manage Users</h1>
<div v-for="user in users" :key="user.id" class="flex flex-row items-center gap-2">
<div
v-for="user in users"
:key="user.id"
class="flex flex-row items-center gap-2"
>
<span>{{ user.username }}</span>
<span v-if="user.admin">(admin)</span>
<Button
v-if="user.id !== auth.user.id"
@click="toggleAdmin(user)"
>
<Button v-if="user.id !== auth.user.id" @click="toggleAdmin(user)">
{{ user.admin ? "Demote" : "Promote" }}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
import { useTemplateRef, ref, onMounted, onUnmounted } from "vue";
const display = useTemplateRef('display')
const display = useTemplateRef("display");
const displayText = ref("");
const charHeight: number = 14;
@@ -12,31 +12,35 @@ let m: number;
function setup() {
display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`;
fillDisplay()
fillDisplay();
}
function fillDisplay() {
// M rows N columns
m = Math.floor(display.value.offsetHeight / charHeight);
n = Math.floor(display.value.offsetWidth / charWidth);
const row = ' '.repeat(n);
displayText.value = (row + '\n').repeat(m - 1) + row
const row = " ".repeat(n);
displayText.value = (row + "\n").repeat(m - 1) + row;
}
function close() {
displayText.value = ""
displayText.value = "";
}
onMounted(() => {
setup()
})
setup();
});
onUnmounted(() => {
close()
})
close();
});
</script>
<template>
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
}}</pre>
<pre
class="overflow-scroll w-full h-full bg-black text-white m-0 p-0"
id="container"
ref="display"
>{{ displayText }}</pre
>
</template>

View File

@@ -5,7 +5,9 @@ import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
const CreateBookmark = defineAsyncComponent(() => import("@/views/admin/CreateBookmark.vue"));
const CreateBookmark = defineAsyncComponent(
() => import("@/views/admin/CreateBookmark.vue"),
);
const homeData = useHomeDataStore();
const authStore = useAuthStore();
@@ -27,12 +29,21 @@ const groupedBookmarks = computed(() => {
<Header class="text-left">
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Bookmark" : "Bookmarks" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateBookmark v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreateBookmark
v-if="showCreate"
class="flex-1 min-h-0 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div v-if="!showCreate" class="bookmarks-scroll">
<LinkTable
v-for="group in groupedBookmarks"

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useActivityStore } from "@/stores/activity";
import { useAuthStore } from "@/stores/auth";
const CreateActivity = defineAsyncComponent(() => import("@/views/admin/CreateActivity.vue"));
const CreateActivity = defineAsyncComponent(
() => import("@/views/admin/CreateActivity.vue"),
);
const activityStore = useActivityStore();
const authStore = useAuthStore();
@@ -19,14 +21,27 @@ const showCreate = ref(false);
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Activity" : "Consumption" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateActivity v-if="showCreate" class="flex-1 w-full p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreateActivity
v-if="showCreate"
class="flex-1 w-full p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="flex-1 w-full">
<LinkTable variant="table" class="w-full" :items="activityStore.activity" />
<LinkTable
variant="table"
class="w-full"
:items="activityStore.activity"
/>
</AutoScroll>
</div>
</template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useFavoritesStore } from "@/stores/favorites";
import { useAuthStore } from "@/stores/auth";
const CreateFavorite = defineAsyncComponent(() => import("@/views/admin/CreateFavorite.vue"));
const CreateFavorite = defineAsyncComponent(
() => import("@/views/admin/CreateFavorite.vue"),
);
const favoritesStore = useFavoritesStore();
const authStore = useAuthStore();
@@ -19,12 +21,21 @@ const showCreate = ref(false);
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Favorite" : "favs" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateFavorite v-if="showCreate" class="w-full flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreateFavorite
v-if="showCreate"
class="w-full flex-1 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="w-full flex-1">
<LinkTable
variant="table"

View File

@@ -7,7 +7,9 @@ import { ref, computed, defineAsyncComponent } from "vue";
import { useAuthStore } from "@/stores/auth";
import { usePostsStore } from "@/stores/posts";
const CreatePost = defineAsyncComponent(() => import("@/views/admin/CreatePost.vue"));
const CreatePost = defineAsyncComponent(
() => import("@/views/admin/CreatePost.vue"),
);
const authStore = useAuthStore();
const postsStore = usePostsStore();
@@ -45,38 +47,33 @@ function deletePost() {
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Post" : post.title }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreatePost v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreatePost
v-if="showCreate"
class="flex-1 min-h-0 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div
v-if="!showCreate"
class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
>
<small
>Created at:
{{ new Date(post.createdAt).toLocaleString() }}</small
>
<small>Created at: {{ new Date(post.createdAt).toLocaleString() }}</small>
<small>By: {{ post.author.username }}</small>
<Markdown
class="flex-1 border-box text-wrap"
:source="post.content"
/>
<Markdown class="flex-1 border-box text-wrap" :source="post.content" />
<div class="flex flex-row w-full">
<Button
class="flex-1 border-box"
v-if="!leftCap"
@click="prevPost"
>
<Button class="flex-1 border-box" v-if="!leftCap" @click="prevPost">
Prev
</Button>
<Button
class="flex-1 border-box"
v-if="!rightCap"
@click="nextPost"
>
<Button class="flex-1 border-box" v-if="!rightCap" @click="nextPost">
Next
</Button>
</div>

View File

@@ -5,7 +5,9 @@ import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia";
const CreateRowing = defineAsyncComponent(() => import("@/views/admin/CreateRowing.vue"));
const CreateRowing = defineAsyncComponent(
() => import("@/views/admin/CreateRowing.vue"),
);
const store = useHomeDataStore();
const authStore = useAuthStore();
@@ -117,13 +119,22 @@ function formatValue(key, val) {
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Upload Rowing" : "Rowing" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateRowing v-if="showCreate" class="flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreateRowing
v-if="showCreate"
class="flex-1 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div v-else-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p>
</div>

View File

@@ -44,9 +44,7 @@ import Bookmarks from "./Bookmarks.vue";
loading="lazy"
/>
</div>
<div
class="a4page-portrait homeGrid relative bdr-1 border-quaternary"
>
<div class="a4page-portrait homeGrid relative bdr-1 border-quaternary">
<!-- <Intro class="intro" /> -->
<Intro2 class="intro grid-cell" />
<!-- <BadApple class="intro" /> -->
@@ -63,9 +61,7 @@ import Bookmarks from "./Bookmarks.vue";
<div class="sidebar">
<Steam class="steam-sidebar sidebar-cell" />
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
<Chat
class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell"
/>
<Chat class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" />
<Miku
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
/>

View File

@@ -4,7 +4,9 @@ import Paragraph from "@/components/text/Paragraph.vue";
</script>
<template>
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start">
<div
class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"
>
<Header>Yo</Header>
<!-- <Header>Intro</Header> -->
<!-- <Paragraph> -->

View File

@@ -112,10 +112,7 @@ onUnmounted(() => {
</script>
<template>
<div
ref="container"
class="w-full h-full relative overflow-hidden"
>
<div ref="container" class="w-full h-full relative overflow-hidden">
<div
v-for="(item, i) in items"
:key="i"

View File

@@ -37,10 +37,11 @@ onUnmounted(() => {
class="flex flex-col items-center pt-2"
>
<Header>Listening To</Header>
<img :src="song.track.album.images[0].url" :alt="song.track.album.name + ' album art'" />
<p class="text-center">
<strong>Song:</strong> {{ song.track.name }}
</p>
<img
:src="song.track.album.images[0].url"
:alt="song.track.album.name + ' album art'"
/>
<p class="text-center"><strong>Song:</strong> {{ song.track.name }}</p>
<p class="text-center">
<strong>Artist:</strong> {{ song.track.artists[0].name }}
</p>

View File

@@ -76,15 +76,20 @@ onUnmounted(() => {
<Touchscreen ref="touchscreen">
<div class="flex flex-wrap tst">
<Link bare href="https://www.adam-french.co.uk">
<img src="https://www.adam-french.co.uk/img/stamps/mine.gif" alt="adam-french.co.uk" />
</Link>
<Link bare href="https://jacobbarron.xyz">
<img
src="https://jacobbarron.xyz/Banneh.gif"
alt="jacobbarron.xyz"
src="https://www.adam-french.co.uk/img/stamps/mine.gif"
alt="adam-french.co.uk"
/>
</Link>
<img v-for="src in srcs" :src="src" :alt="src.split('/').pop().split('.')[0]" loading="lazy" />
<Link bare href="https://jacobbarron.xyz">
<img src="https://jacobbarron.xyz/Banneh.gif" alt="jacobbarron.xyz" />
</Link>
<img
v-for="src in srcs"
:src="src"
:alt="src.split('/').pop().split('.')[0]"
loading="lazy"
/>
</div>
</Touchscreen>
</template>

View File

@@ -17,9 +17,7 @@ const videoSources = [
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
>
<p>
<Link href="https://wiki.teamfortress.com/wiki/Demoman"
>The goat</Link
>
<Link href="https://wiki.teamfortress.com/wiki/Demoman">The goat</Link>
</p>
<div>
<VideoTable :sourceArr="videoSources" />

View File

@@ -4,7 +4,9 @@ import Wip from "@/components/util/Wip.vue";
<template>
<main class="items-center flex flex-col">
<div class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>

View File

@@ -17,22 +17,21 @@ const links = [
<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.
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.
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>

View File

@@ -28,33 +28,31 @@
<h2>BAE graduate digital intelligence software engineer</h2>
<p>
I am writing to express my interest in your software engineering
position. BAE Systems has hosted multiple stools at the
University of Leeds and have always exhibited their development
of leading-edge software and technology. This is where the
origin of my interest in BAE systems emerged and I'm hopeful
that this interest shall continue.
position. BAE Systems has hosted multiple stools at the University of
Leeds and have always exhibited their development of leading-edge
software and technology. This is where the origin of my interest in BAE
systems emerged and I'm hopeful that this interest shall continue.
</p>
<p>
I'm confidient im a strong fit for this role. My technical
background includes extensive experience with frontend
frameworks such as React. My devotion however lies more in
backend development as has more potential to graple problems
related to optimisation and designing coherent interfaces.
I'm confidient im a strong fit for this role. My technical background
includes extensive experience with frontend frameworks such as React. My
devotion however lies more in backend development as has more potential
to graple problems related to optimisation and designing coherent
interfaces.
</p>
<p>
<em> The C# Programming Yellow Book </em> was my first
introduction to C# during A-Level, Java was our vessel for
teaching object-orientated programming at university. I am
confident I have the relevant experience to grasp the languages
stated for the role I am applying for.
<em> The C# Programming Yellow Book </em> was my first introduction to
C# during A-Level, Java was our vessel for teaching object-orientated
programming at university. I am confident I have the relevant experience
to grasp the languages stated for the role I am applying for.
</p>
<p>
My academic background in Computer Science and Mathematics has
honed my ability to translate abstract concepts into structured,
logical solutions. Just as I have transformed theoretical
hypotheses into formal proofs, I aim to transform business
requirements into robust, maintainable software systems through
collaboration and rigorous reasoning.
My academic background in Computer Science and Mathematics has honed my
ability to translate abstract concepts into structured, logical
solutions. Just as I have transformed theoretical hypotheses into formal
proofs, I aim to transform business requirements into robust,
maintainable software systems through collaboration and rigorous
reasoning.
</p>
<p>Thank you for reading - Adam F</p>
</div>