Extract Vue frontend into separate container and add stp_wasm crate
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m58s

Move Vue app from nginx/vue/ to top-level vue/ with its own Dockerfile,
update docker-compose configs and nginx proxy to serve from the new
container, and add initial Rust WASM crate (stp_wasm). Also fix .gitignore
to exclude Rust target/ directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:40:45 +00:00
parent 2b84730126
commit d3d3269d49
182 changed files with 215 additions and 34 deletions

View File

@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { useHomeDataStore } from "@/stores/homeData";
const activity_template = {
type: "activity",
name: "nameof",
createdAt: Date.now(),
};
export const useActivityStore = defineStore("activity", () => {
const activity = ref([activity_template]);
const activityCount = computed(() => activity.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.activities,
(newActivities) => {
if (newActivities.length > 0) {
activity.value = newActivities;
}
},
{ immediate: true },
);
async function fetchActivity() {
await homeData.fetchAll();
}
return {
activity,
activityCount,
fetchActivity,
};
});

90
vue/src/stores/auth.js Normal file
View File

@@ -0,0 +1,90 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
export const useAuthStore = defineStore("auth", () => {
const user = ref({});
const loggedIn = computed(() => !!user.value.username);
const homeData = useHomeDataStore();
watch(
() => homeData.me,
(me) => {
if (me) {
user.value = me;
}
},
{ immediate: true },
);
async function logOut() {
try {
await gql(`mutation { logout }`);
} catch (err) {
console.error(err);
}
user.value = {};
}
async function logIn(username, password) {
try {
const data = await gql(
`mutation Login($input: LoginInput!) { login(input: $input) { user { id username admin } } }`,
{ input: { username, password } },
);
user.value = data.login.user;
} catch (err) {
console.error(err);
}
}
async function createUser(username, password) {
try {
const data = await gql(
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username admin } }`,
{ input: { username, password } },
);
return data.createUser;
} catch (err) {
console.error(err);
throw err;
}
}
async function refreshToken() {
try {
const data = await gql(
`mutation { refreshToken { user { id username admin } } }`,
);
user.value = data.refreshToken.user;
} catch (err) {
console.log(err);
}
}
async function setUserAdmin(userId, admin) {
try {
const data = await gql(
`mutation SetUserAdmin($id: ID!, $admin: Boolean!) { setUserAdmin(id: $id, admin: $admin) { id username admin } }`,
{ id: userId, admin },
);
return data.setUserAdmin;
} catch (err) {
console.error(err);
throw err;
}
}
return {
user,
loggedIn,
logIn,
refreshToken,
logOut,
createUser,
setUserAdmin,
};
});

View File

@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { useHomeDataStore } from "@/stores/homeData";
const favorite_template = {
type: "favorite",
name: "nameof",
createdAt: Date.now(),
};
export const useFavoritesStore = defineStore("favorites", () => {
const favorites = ref([favorite_template]);
const favoritesCount = computed(() => favorites.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.favorites,
(newFavorites) => {
if (newFavorites.length > 0) {
favorites.value = newFavorites;
}
},
{ immediate: true },
);
async function fetchFavorites() {
await homeData.fetchAll();
}
return {
favorites,
favoritesCount,
fetchFavorites,
};
});

View File

@@ -0,0 +1,74 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { gql } from "@/graphql";
import axios from "axios";
export const useHomeDataStore = defineStore("homeData", () => {
const loaded = ref(false);
const error = ref(null);
const me = ref(null);
const posts = ref([]);
const favorites = ref([]);
const activities = ref([]);
const spotifyRecent = ref([]);
const rowingSessions = ref([]);
const gitFeed = ref(null);
const radioLive = ref(false);
async function fetchAll() {
try {
const [data] = await Promise.all([
gql(`
query HomeData {
posts { id title content createdAt updatedAt author { id username } }
favorites { id type name link createdAt }
activities { id type name link createdAt }
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
me { id username admin }
}
`),
fetchRadioStatus(),
]);
posts.value = data.posts;
favorites.value = data.favorites;
activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent;
rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null;
me.value = data.me || null;
loaded.value = true;
} catch (err) {
console.error("HomeData fetch failed:", err);
error.value = err;
}
}
async function fetchRadioStatus() {
try {
await axios.head("/radio/stream");
radioLive.value = true;
} catch {
radioLive.value = false;
}
}
fetchAll();
return {
loaded,
error,
me,
posts,
favorites,
activities,
spotifyRecent,
rowingSessions,
gitFeed,
radioLive,
fetchAll,
fetchRadioStatus,
};
});

103
vue/src/stores/messages.js Normal file
View File

@@ -0,0 +1,103 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import axios from "axios";
function getWebSocketURL() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/api/ws`;
}
export const useMessagesStore = defineStore("messages", () => {
const socket = ref(null);
const messages = ref([]);
const isConnected = ref(false);
const lastError = ref(null);
let intentionalClose = false;
let reconnectDelay = 1000;
let reconnectTimer = null;
const messagesCount = computed(() => messages.value.length);
function connect() {
if (socket.value && isConnected.value) return;
intentionalClose = false;
socket.value = new WebSocket(getWebSocketURL());
socket.value.onopen = () => {
isConnected.value = true;
lastError.value = null;
reconnectDelay = 1000;
messages.value = [];
};
socket.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
messages.value.push(data);
} catch {
messages.value.push({ text: event.data });
}
};
socket.value.onerror = (error) => {
lastError.value = error;
};
socket.value.onclose = () => {
isConnected.value = false;
socket.value = null;
if (!intentionalClose) {
reconnectTimer = setTimeout(() => {
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
}
};
}
function disconnect() {
intentionalClose = true;
clearTimeout(reconnectTimer);
if (!socket.value) return;
socket.value.close();
socket.value = null;
isConnected.value = false;
}
function sendMessage(text) {
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text }));
}
function clearMessages() {
messages.value = [];
}
async function uploadAndSendFile(file) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await axios.post("/api/messages/upload", formData);
const { url } = res.data;
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
} catch (err) {
lastError.value = err;
}
}
return {
messages,
isConnected,
lastError,
messagesCount,
connect,
disconnect,
sendMessage,
clearMessages,
uploadAndSendFile,
};
});

48
vue/src/stores/models.js Normal file
View File

@@ -0,0 +1,48 @@
export class User {
constructor({ id, createdAt, updatedAt, deletedAt, username, admin }) {
this.id = id;
this.createdAt = new Date(createdAt);
this.updatedAt = new Date(updatedAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
this.username = username;
this.admin = admin;
}
}
export class Message {
constructor({ id, text, author, createdAt, deletedAt }) {
this.id = id;
this.content = text;
this.author = author ? new User(author) : null;
this.createdAt = new Date(createdAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
}
}
export class Post {
constructor({
id,
title,
author,
authorID,
content,
createdAt,
updatedAt,
deletedAt,
}) {
this.id = id;
this.title = title;
this.authorID = authorID;
this.author = author ? new User(author) : null;
this.content = content;
this.createdAt = new Date(createdAt);
this.updatedAt = new Date(updatedAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
}
}
// Utility function to parse posts from API
export function parsePosts(postsArray) {
if (!Array.isArray(postsArray)) return [];
return postsArray.map((post) => new Post(post));
}

57
vue/src/stores/posts.js Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const post_template = {
title: "Can't fetch from the db yo",
content:
"This is meant to be pulling from a database, but for some reason that isn't working and this is filler text that should hopefully never see the light of day. If you are reading this, something has gone horribly, horribly wrong. Please start crying and prepare for the incoming wrath of hell. Furthermore, this is very, very long because I am trying to test the scroll feature so thank you ^_^.",
author: {
username: "stp",
},
createdAt: Date.now(),
};
export const usePostsStore = defineStore("posts", () => {
const posts = ref([post_template]);
const postsCount = computed(() => posts.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.posts,
(newPosts) => {
if (newPosts.length > 0) {
posts.value = newPosts;
}
},
{ immediate: true },
);
async function fetchPosts() {
await homeData.fetchAll();
}
async function deletePost(post) {
try {
await gql(
`mutation DeletePost($id: ID!) { deletePost(id: $id) { id } }`,
{ id: post.id },
);
console.log("Deleted:", post.id);
await homeData.fetchAll();
} catch (err) {
console.error("Delete failed:", err);
}
}
return {
posts,
postsCount,
fetchPosts,
deletePost,
};
});

59
vue/src/stores/songs.js Normal file
View File

@@ -0,0 +1,59 @@
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const song_template = {
track: {
name: "^_^",
album: { name: "", images: [{ url: "/img/Untitled.png" }] },
artists: [{ name: ">_<" }],
},
};
export const useSongsStore = defineStore("songs", () => {
const songs = ref([song_template]);
const songsCount = computed(() => songs.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.spotifyRecent,
(newSongs) => {
if (newSongs.length > 0) {
songs.value = newSongs;
}
},
{ immediate: true },
);
async function fetchSongs() {
try {
const data = await gql(`
query {
spotifyRecent {
track {
name
album { name images { url } }
artists { name }
}
playedAt
}
}
`);
if (Array.isArray(data.spotifyRecent) && data.spotifyRecent.length > 0) {
songs.value = data.spotifyRecent;
}
} catch (err) {
console.error("Cannot connect to Spotify API", err);
}
}
return {
songs,
songsCount,
fetchSongs,
};
});