Merge pull request 'dev' (#1) from dev into main
All checks were successful
ci/woodpecker/manual/build-and-publish Pipeline was successful

Reviewed-on: #1
This commit is contained in:
SauceyRed 2025-05-29 03:01:50 +00:00
commit 9f289249a8
18 changed files with 366 additions and 154 deletions

13
app.vue
View file

@ -1,7 +1,13 @@
<template>
<NuxtPage />
<div>
<NuxtPage />
</div>
</template>
<script lang="ts" setup>
</script>
<style>
html,
body {
@ -9,10 +15,13 @@ body {
box-sizing: border-box;
color: rgb(190, 190, 190);
background-color: rgb(30, 30, 30);
height: 100%;
margin: 0;
}
*:focus-visible {
outline: 1px solid rgb(150, 150, 150);
}
a {
color: aquamarine;
}

View file

@ -1,6 +1,6 @@
<template>
<div>
Loading...
<div id="loading-container">
<Icon name="lucide:loader-circle" id="loading-circle" />
</div>
</template>
@ -8,6 +8,31 @@
</script>
<style>
<style scoped>
#loading-container {
position: fixed;
left: 50dvw;
top: 50dvh;
transform: translate(-50%, -50%);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#loading-circle {
animation-name: spin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
font-size: 2rem;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="message">
<div>
<img v-if="props.img" class="message-author-avatar" :src="img" :alt="username">
<img v-if="props.img" class="message-author-avatar" :src="props.img" :alt="username">
<Icon v-else name="lucide:user" class="message-author-avatar" />
</div>
<div class="message-data">
@ -25,7 +25,7 @@
</template>
<script lang="ts" setup>
const props = defineProps<{ class?: string, img?: string, username: string, text: string, timestamp: number, format: "12" | "24" }>();
const props = defineProps<{ class?: string, img?: string | null, username: string, text: string, timestamp: number, format: "12" | "24" }>();
const messageDate = ref<string>();
const showHover = ref(false);
@ -74,6 +74,7 @@ if (now.getUTCHours() >= 0) {
display: flex;
flex-direction: column;
gap: 1dvh;
height: 100%;
}
.message-author {
@ -84,6 +85,7 @@ if (now.getUTCHours() >= 0) {
.message-author-avatar {
margin-right: 1dvw;
width: 3em;
border-radius: 50%;
}
.author-username {

View file

@ -1,13 +1,13 @@
<template>
<div id="message-area">
<div id="messages" ref="messagesElement">
<Message v-for="message of messages" :username="displayNames[message.user_uuid]" :text="message.message"
:timestamp="uuidToTimestamp(message.uuid)" format="12" />
<Message v-for="message of messages" :username="message.user.display_name ?? message.user.username" :text="message.message"
:timestamp="uuidToTimestamp(message.uuid)" :img="message.user.avatar" format="12" />
</div>
<div id="message-box">
<form id="message-form" @submit="sendMessage">
<input v-model="messageInput" type="text" name="message-input" id="message-box-input">
<button type="submit">
<input v-model="messageInput" type="text" name="message-input" id="message-box-input" autocomplete="off">
<button id="submit-button" type="submit">
<Icon name="lucide:send" />
</button>
</form>
@ -17,7 +17,7 @@
<script lang="ts" setup>
import type { MessageResponse } from '~/types/interfaces';
import fetchUser from '~/utils/fetchUser';
import scrollToBottom from '~/utils/scrollToBottom';
const props = defineProps<{ channelUrl: string, amount?: number, offset?: number, reverse?: boolean }>();
@ -31,8 +31,6 @@ if (messagesRes && props.reverse) {
const messages = ref<MessageResponse[]>([]);
const displayNames = ref<Record<string, string>>({});
const route = useRoute();
const messageInput = ref<string>();
@ -40,14 +38,6 @@ const messageInput = ref<string>();
const messagesElement = ref<HTMLDivElement>();
if (messagesRes) messages.value = messagesRes;
const displayNamesArr: Record<string, string> = {};
for (const message of messages.value) {
if (!displayNamesArr[message.user_uuid]) {
const displayName = await getDisplayName(message.user_uuid);
displayNamesArr[message.user_uuid] = displayName;
}
}
displayNames.value = displayNamesArr;
const accessToken = useCookie("access_token").value;
const apiBase = useCookie("api_base").value;
@ -71,23 +61,21 @@ ws.addEventListener("open", (event) => {
console.log("WebSocket connected!");
});
ws.addEventListener("message", (event) => {
ws.addEventListener("message", async (event) => {
console.log("event data:", event.data);
messages.value?.push(
JSON.parse(event.data)
);
await nextTick();
if (messagesElement.value) {
console.log("scrolling to bottom");
scrollToBottom(messagesElement);
}
});
} else {
await refresh();
}
async function getDisplayName(memberId: string): Promise<string> {
//const user = await fetchMember((route.params.serverId as string), memberId);
const user = await fetchUser((route.params.serverId as string), memberId);
return user!.display_name ?? user!.username;
}
function sendMessage(e: Event) {
e.preventDefault();
const text = messageInput.value;
@ -100,7 +88,9 @@ function sendMessage(e: Event) {
}
onMounted(async () => {
messagesElement.value?.scrollTo({ top: messagesElement.value.scrollHeight });
if (messagesElement.value) {
scrollToBottom(messagesElement);
}
});
</script>
@ -108,27 +98,38 @@ onMounted(async () => {
<style scoped>
#message-area {
padding-top: 3dvh;
}
#message-area {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 8fr 1fr;
justify-content: space-between;
padding-top: 3dvh;
padding-left: 1dvw;
padding-right: 1dvw;
overflow: hidden;
}
#message-box {
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
border: 1px solid rgb(70, 70, 70);
padding-bottom: 1dvh;
padding-top: 1dvh;
margin-bottom: 1dvh;
margin-top: 1dvh;
}
#message-input {
width: 100%;
#message-form {
display: flex;
justify-content: center;
height: 60%;
}
#message-box-input {
width: 80%;
background-color: rgb(50, 50, 50);
border: none;
}
#messages {
@ -138,4 +139,14 @@ onMounted(async () => {
gap: 1dvh;
}
#submit-button {
background-color: inherit;
border: none;
color: white;
}
#submit-button:hover {
background-color: rgb(40, 40, 40);
}
</style>

View file

@ -30,11 +30,11 @@ export const useAuth = () => {
{
username, password: hashedPass, device_name: "Linux Laptop"
}
}) as { access_token: string, refresh_token: string }; fetch
}) as { access_token: string, refresh_token: string };
console.log("hi");
accessToken.value = res.access_token;
console.log("access token:", accessToken.value);
await fetchUser();
//await fetchUser();
}
async function logout(password: string) {
@ -60,19 +60,21 @@ export const useAuth = () => {
async function refresh() {
console.log("refreshing");
try {
const res = await fetchWithApi("/auth/refresh", {
method: "POST"
}) as { access_token: string };
const res = await fetchWithApi("/auth/refresh", {
method: "POST"
}) as any;
console.log("finished refreshing:", res);
if (res && res.access_token) {
accessToken.value = res.access_token;
console.log("set new access token");
} catch (error) {
console.error("refresh error:", error);
} else {
console.log("refresh didn't return access token");
}
}
async function fetchUser() {
if (!accessToken.value) return;
console.log("fetchuser access token:", accessToken.value);
const res = await fetchWithApi("/users/me") as UserResponse;
user.value = res;
return user.value;

29
error.vue Normal file
View file

@ -0,0 +1,29 @@
<template>
<div id="error-container">
<h2>{{ error?.statusCode }}</h2>
<p>{{ error?.message }}</p>
</div>
</template>
<script lang="ts" setup>
import type { NuxtError } from '#app';
const props = defineProps({
error: Object as () => NuxtError
});
if (props.error?.statusCode == 401) {
console.log("HELLO THERE");
clearError({ redirect: `/login?redirect_to=${useRoute().fullPath}` });
}
</script>
<style scoped>
#error-container {
text-align: center;
margin: auto;
}
</style>

View file

@ -1,7 +1,6 @@
<template>
<div id="root-container" style="margin-top: 5dvh;">
<Loading v-if="!mounted" />
<div v-else id="main-container">
<div id="main-container">
<div v-if="!instanceUrl">
<div v-if="instanceError" style="color: red;">
{{ instanceError }}
@ -49,7 +48,6 @@
<script lang="ts" setup>
import { FetchError } from 'ofetch';
const mounted = ref(false);
const redirectTo = useRoute().query.redirect_to;
const apiVersion = useRuntimeConfig().public.apiVersion;
@ -64,7 +62,6 @@ if (auth.accessToken.value) {
}
onMounted(() => {
mounted.value = true;
const cookie = useCookie("instance_url").value;
instanceUrl.value = cookie;
console.log(cookie);

View file

@ -1,5 +1,6 @@
<template>
<div id="client-root">
<Loading v-show="loading" />
<div :class="{ hidden: loading, visible: !loading }" id="client-root">
<div id="homebar">
<div class="homebar-item">
main bar
@ -21,6 +22,8 @@
<script lang="ts" setup>
const loading = useState("loading", () => false);
const servers = [
{
name: "Test",
@ -90,11 +93,21 @@ function sendMessage(e: Event) {
<style>
#client-root {
/* border: 1px solid white; */
height: 100%;
height: 100dvh;
display: grid;
grid-template-columns: 1fr 4fr 18fr 4fr;
grid-template-rows: 4dvh auto;
text-align: center;
}
.hidden {
opacity: 0%;
}
.visible {
opacity: 100%;
transition-duration: 500ms;
}
#homebar {
@ -114,7 +127,6 @@ function sendMessage(e: Event) {
#__nuxt {
display: flex;
flex-flow: column;
height: 100%;
}
.grid-column {

24
middleware/auth.global.ts Normal file
View file

@ -0,0 +1,24 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
console.log("to.fullPath:", to.fullPath);
const loading = useState("loading");
const accessToken = useCookie("access_token").value;
if (["/login", "/register"].includes(to.path)) {
if (accessToken) {
return await navigateTo("/");
}
return;
};
if (!accessToken) {
loading.value = true;
console.log("set loading to true");
const { refresh } = useAuth();
console.log("hi");
await refresh();
const query = new URLSearchParams();
query.set("redirect_to", to.path);
loading.value = false;
console.log("set loading to false");
return await navigateTo("/login?" + (query ?? ""));
}
})

View file

@ -3,13 +3,14 @@ export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxt/image', "@pinia/nuxt", "@nuxt/icon"],
ssr: false,
app: {
/*
Defines what prefix the client runs on
E.g.: baseURL set to "/web" would host at https://gorb.app/web
Default is "/" (aka root), which hosts at https://gorb.app/
*/
baseURL: "/web",
baseURL: "/",
head: {
title: 'Gorb',
// this is purely used to embed in that other chat app, and similar stuff

17
pages/index.vue Normal file
View file

@ -0,0 +1,17 @@
<template>
<NuxtLayout>
</NuxtLayout>
</template>
<script lang="ts" setup>
definePageMeta({
layout: "client"
});
</script>
<style>
</style>

View file

@ -1,46 +1,60 @@
<template>
<NuxtLayout>
<form @submit="formLogin">
<div>
<label for="username">Username/Email</label>
<br>
<input type="text" name="username" id="username" v-model="form.username">
</div>
<div>
<label for="password">Password</label>
<br>
<input type="password" name="password" id="password" v-model="form.password">
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
<div>
Don't have an account? <NuxtLink href="/register">Register</NuxtLink> one!
</div>
</NuxtLayout>
<NuxtLayout>
<form @submit="formLogin">
<div>
<label for="username">Username/Email</label>
<br>
<input type="text" name="username" id="username" v-model="form.username">
</div>
<div>
<label for="password">Password</label>
<br>
<input type="password" name="password" id="password" v-model="form.password">
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
<div>
Don't have an account? <NuxtLink :href="registerUrl">Register</NuxtLink> one!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
definePageMeta({
layout: "auth"
layout: "auth"
})
const form = reactive({
username: "",
password: "",
username: "",
password: "",
});
//const authStore = useAuthStore();
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const registerUrl = `/register?${searchParams}`
const { login } = useAuth();
async function formLogin(e: Event) {
e.preventDefault();
console.log("Sending login data");
await login(form.username, form.password, "Linux Laptop");
//return navigateTo(redirectTo ? redirectTo as string : useAppConfig().baseURL as string);
e.preventDefault();
console.log("Sending login data");
try {
await login(form.username, form.password, "Linux Laptop");
console.log("logged in");
if (query.redirect_to) {
console.log("redirecting to:", query.redirect_to);
return await navigateTo(query.redirect_to);
}
return await navigateTo("/");
} catch (error) {
console.error("Error logging in:", error);
}
//return navigateTo(redirectTo ? redirectTo as string : useAppConfig().baseURL as string);
}
</script>

View file

@ -33,7 +33,7 @@
</div>
</form>
<div>
Already have an account? <NuxtLink href="/login">Log in</NuxtLink>!
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
@ -74,7 +74,9 @@ const errorMessages = reactive({
//const authStore = useAuthStore();
const auth = useAuth();
const redirectTo = useRoute().query.redirect_to;
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const loginUrl = `/login?${searchParams}`
onMounted(() => {
if (auth.accessToken.value) {
@ -120,7 +122,12 @@ const apiVersion = useRuntimeConfig().public.apiVersion;
async function register(e: Event) {
e.preventDefault();
console.log("Sending registration data");
await auth.register(form.username, form.email, form.password);
try {
await auth.register(form.username, form.email, form.password);
return await navigateTo(query.redirect_to);
} catch (error) {
console.error("Error registering:", error);
}
//return navigateTo(redirectTo ? redirectTo as string : useAppConfig().baseURL as string);
}

View file

@ -39,25 +39,20 @@
const route = useRoute();
const server: GuildResponse | undefined = await fetchWithApi(`servers/${route.params.serverId}`);
const channels: ChannelResponse[] | undefined = await fetchWithApi(
`servers/${route.params.serverId}/channels`
);
const channel: ChannelResponse | undefined = await fetchWithApi(
route.path
);
const loading = useState("loading");
const channelUrlPath = `servers/${route.params.serverId}/channels/${route.params.channelId}`;
console.log("channel:", channel);
const server = ref<GuildResponse | undefined>();
const channels = ref<ChannelResponse[] | undefined>();
const channel = ref<ChannelResponse | undefined>();
const showInvitePopup = ref(false);
import type { ChannelResponse, GuildResponse, MessageResponse } from "~/types/interfaces";
//const servers = await fetchWithApi("/servers") as { uuid: string, name: string, description: string }[];
//console.log("servers:", servers);
//console.log("channelid: servers:", servers);
const members = [
{
id: "3287484395",
@ -106,6 +101,23 @@ const members = [
}
];
onMounted(async () => {
loading.value = true;
console.log("channelid: set loading to true");
server.value = await fetchWithApi(`servers/${route.params.serverId}`);
channels.value = await fetchWithApi(
`servers/${route.params.serverId}/channels`
);
channel.value = await fetchWithApi(
route.path
);
console.log("channelid: channel:", channel);
loading.value = false;
console.log("channelid: set loading to false");
});
function showServerSettings() { }
function toggleInvitePopup(e: Event) {

32
pages/verify-email.vue Normal file
View file

@ -0,0 +1,32 @@
<template>
<div id="container">
<div v-if="errorMessage">
{{ errorMessage }}
</div>
</div>
</template>
<script lang="ts" setup>
const errorMessage = ref();
const token = useRoute().query.token;
try {
const res = await fetchWithApi("/auth/verify-email", { query: { token } });
console.log("hi");
} catch (error) {
console.error("Error verifying email:", error);
errorMessage.value = error;
}
</script>
<style scoped>
#container {
text-align: center;
margin: auto;
}
</style>

View file

@ -32,10 +32,11 @@ export interface ChannelResponse {
}
export interface MessageResponse {
uuid: string
channel_uuid: string
user_uuid: string
message: string
uuid: string,
channel_uuid: string,
user_uuid: string,
message: string,
user: UserResponse
}
export interface InviteResponse {
@ -49,6 +50,6 @@ export interface UserResponse {
username: string,
display_name: string | null,
avatar: string | null,
email: string,
email_verified: boolean
email?: string,
email_verified?: boolean
}

View file

@ -9,63 +9,74 @@ export default async <T>(path: string, options: NitroFetchOptions<string> = {})
path = path.slice(0, path.lastIndexOf("/"));
}
console.log("formatted path:", path);
try {
const accessToken = useCookie("access_token");
console.log("access token:", accessToken.value);
const apiBase = useCookie("api_base").value;
const apiVersion = useRuntimeConfig().public.apiVersion;
console.log("heyoooo")
console.log("apiBase:", apiBase);
if (!apiBase) {
console.log("no api base");
return;
}
console.log("path:", path)
const { revoke, refresh } = useAuth();
console.log("access token 2:", accessToken.value);
let headers: HeadersInit = {};
if (accessToken.value) {
headers = {
...options.headers,
"Authorization": `Bearer ${accessToken.value}`
};
} else {
headers = {
...options.headers
};
}
let reauthFailed = false;
while (!reauthFailed) {
try {
console.log("fetching:", URL.parse(apiBase + path));
const res = await $fetch<T>(URL.parse(apiBase + path)!.href, {
...options,
headers,
credentials: "include"
});
return res;
} catch (error: any) {
if (error?.response?.status === 401) {
if (!path.startsWith("/auth/refresh")) {
try {
await refresh();
} catch (error: any) {
if (error?.response?.status === 401) {
reauthFailed = true;
await revoke();
return;
}
const accessToken = useCookie("access_token");
console.log("access token:", accessToken.value);
const apiBase = useCookie("api_base").value;
const apiVersion = useRuntimeConfig().public.apiVersion;
console.log("heyoooo")
console.log("apiBase:", apiBase);
if (!apiBase) {
console.log("no api base");
return;
}
console.log("path:", path)
const { revoke, refresh } = useAuth();
console.log("access token 2:", accessToken.value);
let headers: HeadersInit = {};
if (accessToken.value) {
headers = {
...options.headers,
"Authorization": `Bearer ${accessToken.value}`
};
} else {
headers = {
...options.headers
};
}
let reauthFailed = false;
while (!reauthFailed) {
try {
console.log("fetching:", URL.parse(apiBase + path));
const res = await $fetch<T>(URL.parse(apiBase + path)!.href, {
...options,
headers,
credentials: "include"
});
return res;
} catch (error: any) {
console.error("Error fetching resource");
if (error?.response?.status === 401) {
console.log("Error status is 401");
if (!path.startsWith("/auth/refresh")) {
console.log("Path is not refresh endpoint");
try {
console.log("Trying to refresh");
await refresh();
console.log("Successfully refreshed token");
} catch (error: any) {
console.log("Failed to refresh token");
if (error?.response?.status === 401) {
console.log("Refresh returned 401");
reauthFailed = true;
console.log("Revoking");
await revoke();
console.log("Redirecting to login");
await navigateTo("/login");
console.log("redirected");
return;
}
}
} else {
console.log("Path is refresh endpoint, throwing error");
throw error;
}
throw error;
}
console.log("throwing error");
throw error;
}
} catch (error) {
console.error("error:", error);
}
}

6
utils/scrollToBottom.ts Normal file
View file

@ -0,0 +1,6 @@
export default (element: Ref<HTMLElement | undefined, HTMLElement | undefined>) => {
if (element.value) {
element.value.scrollTo({ top: element.value.scrollHeight });
return;
}
}