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 - subnet: 172.28.0.0/16
volumes: volumes:
# Postgres database
dbdata: dbdata:
# File upload
uploads: uploads:
# Vue build
vue_dist: vue_dist:
# Searxng data
searxng_data: searxng_data:
services: services:

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Check if dev mode, certificate exists, or setup mode # Check if DEV_MODE
if [ "$DEV_MODE" = "true" ]; then if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Generating self-signed certificate for HTTPS." echo "Dev mode. Generating self-signed certificate for HTTPS."
CERT_DIR="/etc/letsencrypt/live/localhost" CERT_DIR="/etc/letsencrypt/live/localhost"
@@ -12,16 +12,19 @@ if [ "$DEV_MODE" = "true" ]; then
-out "$CERT_DIR/fullchain.pem" \ -out "$CERT_DIR/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null -subj "/CN=localhost" 2>/dev/null
fi 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}' \ 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_dev.conf.template \
>/etc/nginx/nginx.conf >/etc/nginx/nginx.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config." 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}' \ 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.template \
>/etc/nginx/nginx.conf >/etc/nginx/nginx.conf
else else
echo "Certificates NOT found. Using setup nginx config." 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 envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
fi fi

View File

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

View File

@@ -20,7 +20,13 @@ const computedRel = computed(() => {
<RouterLink v-if="to" :to="to" :class="{ link: !bare }"> <RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot /> <slot />
</RouterLink> </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 /> <slot />
</a> </a>
</template> </template>

View File

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

View File

@@ -20,8 +20,7 @@ let resizeObserver = null;
function scrollToBottom() { function scrollToBottom() {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
messagesContainer.value.scrollHeight;
} }
} }
@@ -146,9 +145,7 @@ onUnmounted(() => {
> >
<span class="text-tertiary">{{ message.authorId }}:</span> <span class="text-tertiary">{{ message.authorId }}:</span>
<template <template
v-for="(part, i) in parseMessageParts( v-for="(part, i) in parseMessageParts(message.text || '')"
message.text || '',
)"
:key="i" :key="i"
> >
<Link <Link
@@ -161,9 +158,7 @@ onUnmounted(() => {
> >
<span v-else>{{ part.value }}</span> <span v-else>{{ part.value }}</span>
</template> </template>
<template <template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img <img
v-if="isImageUrl(message.fileUrl)" v-if="isImageUrl(message.fileUrl)"
:src="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" class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden"
> >
<h3>Last git activity</h3> <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"> <Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3> <h3>repo: {{ feed.repoName }}</h3>
</Link> </Link>

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import axios from "axios";
export async function gql(query, variables = {}) { export async function gql(query, variables = {}) {
const res = await axios.post("/api/graphql", { 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; return res.data.data;
} }

View File

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

View File

@@ -91,12 +91,19 @@ router.beforeEach(async (to) => {
const homeData = useHomeDataStore(); const homeData = useHomeDataStore();
if (!homeData.loaded) { if (!homeData.loaded) {
await new Promise((resolve) => { await new Promise((resolve) => {
const stop = watch(() => homeData.loaded, (val) => { const stop = watch(
if (val) { stop(); resolve(); } () => 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; export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,13 +31,14 @@ onMounted(fetchUsers);
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<h1>Manage Users</h1> <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>{{ user.username }}</span>
<span v-if="user.admin">(admin)</span> <span v-if="user.admin">(admin)</span>
<Button <Button v-if="user.id !== auth.user.id" @click="toggleAdmin(user)">
v-if="user.id !== auth.user.id"
@click="toggleAdmin(user)"
>
{{ user.admin ? "Demote" : "Promote" }} {{ user.admin ? "Demote" : "Promote" }}
</Button> </Button>
</div> </div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <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 displayText = ref("");
const charHeight: number = 14; const charHeight: number = 14;
@@ -12,31 +12,35 @@ let m: number;
function setup() { function setup() {
display.value.style.fontSize = `${charHeight}px`; display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`; display.value.style.lineHeight = `${charHeight}px`;
fillDisplay() fillDisplay();
} }
function fillDisplay() { function fillDisplay() {
// M rows N columns // M rows N columns
m = Math.floor(display.value.offsetHeight / charHeight); m = Math.floor(display.value.offsetHeight / charHeight);
n = Math.floor(display.value.offsetWidth / charWidth); n = Math.floor(display.value.offsetWidth / charWidth);
const row = ' '.repeat(n); const row = " ".repeat(n);
displayText.value = (row + '\n').repeat(m - 1) + row displayText.value = (row + "\n").repeat(m - 1) + row;
} }
function close() { function close() {
displayText.value = "" displayText.value = "";
} }
onMounted(() => { onMounted(() => {
setup() setup();
}) });
onUnmounted(() => { onUnmounted(() => {
close() close();
}) });
</script> </script>
<template> <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> </template>

View File

@@ -5,7 +5,9 @@ import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData"; import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth"; 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 homeData = useHomeDataStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -27,12 +29,21 @@ const groupedBookmarks = computed(() => {
<Header class="text-left"> <Header class="text-left">
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Bookmark" : "Bookmarks" }} {{ 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" : "+" }} {{ showCreate ? "x" : "+" }}
</button> </button>
</span> </span>
</Header> </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"> <div v-if="!showCreate" class="bookmarks-scroll">
<LinkTable <LinkTable
v-for="group in groupedBookmarks" v-for="group in groupedBookmarks"

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useActivityStore } from "@/stores/activity"; import { useActivityStore } from "@/stores/activity";
import { useAuthStore } from "@/stores/auth"; 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 activityStore = useActivityStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -19,14 +21,27 @@ const showCreate = ref(false);
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Activity" : "Consumption" }} {{ 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" : "+" }} {{ showCreate ? "x" : "+" }}
</button> </button>
</span> </span>
</Header> </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"> <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> </AutoScroll>
</div> </div>
</template> </template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useFavoritesStore } from "@/stores/favorites"; import { useFavoritesStore } from "@/stores/favorites";
import { useAuthStore } from "@/stores/auth"; 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 favoritesStore = useFavoritesStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -19,12 +21,21 @@ const showCreate = ref(false);
<Header> <Header>
<span class="flex items-center justify-between w-full"> <span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Favorite" : "favs" }} {{ 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" : "+" }} {{ showCreate ? "x" : "+" }}
</button> </button>
</span> </span>
</Header> </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"> <AutoScroll v-if="!showCreate" class="w-full flex-1">
<LinkTable <LinkTable
variant="table" variant="table"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,15 +76,20 @@ onUnmounted(() => {
<Touchscreen ref="touchscreen"> <Touchscreen ref="touchscreen">
<div class="flex flex-wrap tst"> <div class="flex flex-wrap tst">
<Link bare 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" alt="adam-french.co.uk" />
</Link>
<Link bare href="https://jacobbarron.xyz">
<img <img
src="https://jacobbarron.xyz/Banneh.gif" src="https://www.adam-french.co.uk/img/stamps/mine.gif"
alt="jacobbarron.xyz" alt="adam-french.co.uk"
/> />
</Link> </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> </div>
</Touchscreen> </Touchscreen>
</template> </template>

View File

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

View File

@@ -4,7 +4,9 @@ import Wip from "@/components/util/Wip.vue";
<template> <template>
<main class="items-center flex flex-col"> <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 /> <Wip />
</div> </div>
</main> </main>

View File

@@ -17,22 +17,21 @@ const links = [
<section> <section>
<Header>Adam French</Header> <Header>Adam French</Header>
<Paragraph> <Paragraph>
Junior software engineer focused on full-stack development, Junior software engineer focused on full-stack development, systems
systems programming, and infrastructure. First Class Honours programming, and infrastructure. First Class Honours in Computer
in Computer Science with Mathematics from Leeds and Science with Mathematics from Leeds and Waterloo.
Waterloo.
</Paragraph> </Paragraph>
</section> </section>
<section> <section>
<Header>About</Header> <Header>About</Header>
<Paragraph> <Paragraph>
This website is self-hosted and has a lot more on it than it This website is self-hosted and has a lot more on it than it needs to.
needs to. Please have a look at my Please have a look at my
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of <InlineLink to="/cv">CV</InlineLink> for a full breakdown of my
my experience, projects, and skills. Please visit experience, projects, and skills. Please visit
<InlineLink to="/stp">STP</InlineLink> for the prefered but <InlineLink to="/stp">STP</InlineLink> for the prefered but less
less professional experience. professional experience.
</Paragraph> </Paragraph>
</section> </section>

View File

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