Merge remote-tracking branch 'origin/main' into client-settings-refactor
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful

This commit is contained in:
Twig 2025-07-12 21:58:49 +02:00
commit 3e0ce16cce
No known key found for this signature in database
34 changed files with 382 additions and 63 deletions

View file

@ -0,0 +1,63 @@
<template>
<div style="text-align: left;">
<h3>Add a Friend</h3>
Enter a friend's Gorb username to send them a friend request.
</div>
<div id="add-friend-search-bar">
<input id="add-friend-search-input" ref="inputField"
placeholder="blahaj.enjoyer" maxlength="32" @keypress.enter="sendRequest"/> <!-- REMEMBER TO CHANGE THIS WHEN WE ADD FEDERATION-->
<Button id="friend-request-button" :callback="sendRequest" text="Send Friend Request"></Button>
</div>
</template>
<script lang="ts" setup>
import Button from '../UserInterface/Button.vue';
const inputField = ref<HTMLInputElement>();
const { addFriend } = useApi();
async function sendRequest() {
if (inputField.value) {
try {
await addFriend(inputField.value.value)
alert("Friend request sent!")
} catch {
alert("Request failed :(")
}
}
}
</script>
<style>
#add-friend-search-bar {
display: flex;
text-align: left;
margin-top: .8em;
padding: .3em .3em;
border-radius: 1em;
border: 1px solid var(--accent-color);
}
#add-friend-search-input {
border: none;
box-sizing: border-box;
margin: 0 .2em;
flex-grow: 1;
color: inherit;
background-color: unset;
font-weight: medium;
letter-spacing: .04em;
}
#add-friend-search-input:empty:before {
content: attr(placeholder);
color: gray;
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div id="middle-left-column">
<div id="friend-sidebar">
<div>
<h3>Direct Messages</h3>
</div>
<VerticalSpacer />
<NuxtLink class="user-item" :href="`/me/friends`" tabindex="0">
<Icon class="user-avatar" name="lucide:user" />
<span class="user-display-name">Friends</span>
</NuxtLink>
<VerticalSpacer />
<div id="direct-message-list">
<UserEntry v-for="user of friends" :user="user" :name="user.display_name || user.username"
:href="`/me/${user.uuid}`"/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
const { fetchFriends } = useApi();
const friends = await fetchFriends()
</script>
<style>
#middle-left-column {
background: var(--optional-channel-list-background);
background-color: var(--sidebar-background-color);
}
#friend-sidebar {
padding-left: .5em;
padding-right: .5em;
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<input id="search-friend-bar" placeholder="search"/>
<!-- we aren't checking for the "all" variant, since this is the default and fallback one -->
<p v-if="props.variant === 'online'" style="text-align: left;">Online 0</p>
<p v-else-if="props.variant === 'pending'" style="text-align: left;">Friend Requests 0</p>
<p v-else style="text-align: left;">Friends {{ friends?.length || 0 }}</p>
<div id="friends-list">
<div v-if="props.variant === 'online'">
Not Implemented
</div>
<div v-else-if="props.variant === 'pending'">
Not Implemented
</div>
<div v-else>
<UserEntry v-for="user of friends" :user="user" :name="user.display_name || user.username"
:href="`/me/${user.uuid}`"/>
</div>
</div>
</template>
<script lang="ts" setup>
const { fetchFriends } = useApi();
const friends = await fetchFriends()
const props = defineProps<{
variant: string
}>();
</script>
<style>
#search-friend-bar {
text-align: left;
margin-top: .8em;
padding: .3em .5em;
width: 100%;
border-radius: 1em;
border: 1px solid var(--accent-color);
box-sizing: border-box;
color: inherit;
background-color: unset;
font-weight: medium;
letter-spacing: .04em;
}
#search-friend-bar:empty:before {
content: attr(placeholder);
color: gray;
}
</style>

View file

@ -10,7 +10,6 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { GuildMemberResponse } from '~/types/interfaces';
import UserPopup from './UserPopup.vue';
const props = defineProps<{
member: GuildMemberResponse

View file

@ -2,6 +2,36 @@
<div v-if="props.type == 'normal' || props.replyMessage" ref="messageElement" @contextmenu="createContextMenu($event, menuItems)" :id="props.last ? 'last-message' : undefined"
class="message normal-message" :class="{ 'mentioned': (props.replyMessage || props.isMentioned) && props.message.user.uuid != props.me.uuid && props.replyMessage?.user.uuid == props.me.uuid }" :data-message-id="props.messageId"
:editing.sync="props.editing" :replying-to.sync="props.replyingTo">
<div v-if="props.replyMessage" class="message-reply-svg">
<svg
width="1.5em"
height="1.5em"
viewBox="0 0 151.14355 87.562065"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
style="overflow: visible;">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(40,-35)">
<g
id="g3"
transform="translate(-35,-20)">
<path
style="stroke:var(--reply-text-color);stroke-width:8;stroke-opacity:1"
d="m 120.02168,87.850978 100.76157,2.4e-5"
id="path3-5" />
<path
style="stroke:var(--reply-text-color);stroke-width:8;stroke-opacity:1"
d="M 69.899501,174.963 120.2803,87.700931"
id="path3-5-2" />
</g>
</g>
</svg>
</div>
<MessageReply v-if="props.replyMessage" :author="props.replyMessage.user.display_name || props.replyMessage.user.username" :text="props.replyMessage?.message"
:id="props.message.uuid" :reply-id="props.replyMessage.uuid" max-width="reply" />
<div class="left-column">
@ -211,6 +241,11 @@ function getDayDifference(date1: Date, date2: Date) {
background-color: rgba(90, 255, 200, 0.233);
}
.message-reply-svg {
display: flex;
justify-content: center;
}
</style>
<style module>

View file

@ -1,7 +1,7 @@
<template>
<div :id="props.maxWidth == 'full' ? 'message-reply' : undefined" :class="{ 'message-reply-preview' : props.maxWidth == 'reply' }"
:data-message-id="props.id" @click="scrollToReply">
<p id="reply-text">Replying to <span id="reply-author-field">{{ props.author }}:</span> <span v-html="sanitized"></span></p>
<span id="reply-text">Replying to <span id="reply-author-field">{{ props.author }}:</span> <span v-html="sanitized"></span></span>
<!-- <span id="message-reply-cancel"><Icon name="lucide:x" /></span> -->
</div>
</template>
@ -61,7 +61,6 @@ function scrollToReply(e: MouseEvent) {
#message-reply, .message-reply-preview {
display: flex;
text-align: left;
border-bottom: 1px solid var(--padding-color);
margin-bottom: .5rem;
cursor: pointer;
overflow: hidden;
@ -72,16 +71,17 @@ function scrollToReply(e: MouseEvent) {
}
.message-reply-preview {
width: 30%;
margin-left: .5dvw;
}
#reply-text {
color: rgb(150, 150, 150);
color: var(--reply-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0;
margin-top: .2rem;
border-bottom: 1px solid var(--padding-color);
}

View file

@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
import Button from '~/components/Button.vue';
import Button from '~/components/UserInterface/Button.vue';
import type { UserResponse } from '~/types/interfaces';
const { fetchUser } = useAuth();

View file

@ -5,7 +5,6 @@
</template>
<script lang="ts" setup>
import Button from '~/components/Button.vue';
</script>
<style scoped>

View file

@ -33,6 +33,9 @@
</template>
<script lang="ts" setup>
import UserPopup from '~/components/User/UserPopup.vue';
import Button from '~/components/UserInterface/Button.vue';
import type { UserResponse } from '~/types/interfaces';
let newPfpFile: File;

View file

@ -0,0 +1,40 @@
<template>
<NuxtLink class="user-item" :href="`/me/${user.uuid}`" tabindex="0">
<img v-if="props.user.avatar" class="user-avatar" :src="props.user.avatar" :alt="props.user.display_name ?? props.user.username" />
<Icon v-else class="user-avatar" name="lucide:user" />
<span class="user-display-name">{{ props.user.display_name || props.user.username }}</span>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const props = defineProps<{
user: UserResponse
}>();
</script>
<style>
.user-item {
display: flex;
align-items: center;
text-align: left;
margin-top: .5em;
margin-bottom: .5em;
gap: .5em;
text-decoration: none;
color: inherit;
}
.user-item:hover {
background-color: #00000020
}
.user-avatar {
width: 2.3em;
height: 2.3em;
border-radius: 50%;
}
</style>

View file

@ -1,20 +0,0 @@
<template>
<div id="user-panel">
HELLO!!
</div>
</template>
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const props = defineProps<{
user: UserResponse,
}>();
</script>
<style scoped>
#user-panel {
width: 100%;
}
</style>

View file

@ -0,0 +1,12 @@
<template>
<span class="spacer"></span>
</template>
<style scoped>
.spacer {
height: 0.2dvh;
display: block;
margin: 0.8dvh 0.2dvw;
background-color: var(--padding-color);
}
</style>

View file

@ -1,4 +1,4 @@
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse } from "~/types/interfaces";
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse, UserResponse } from "~/types/interfaces";
export const useApi = () => {
async function fetchGuilds(): Promise<GuildResponse[] | undefined> {
@ -24,14 +24,26 @@ export const useApi = () => {
async function fetchMember(guildId: string, memberId: string): Promise<GuildMemberResponse | undefined> {
return await fetchWithApi(`/guilds/${guildId}/members/${memberId}`);
}
async function fetchUsers() {
return await fetchWithApi(`/users`);
}
async function fetchUser(userId: string) {
return await fetchWithApi(`/users/${userId}`);
}
async function fetchFriends(): Promise<UserResponse[] | undefined> {
return await fetchWithApi('/me/friends')
}
async function addFriend(username: string): Promise<void> {
return await fetchWithApi('/me/friends', { method: "POST", body: { username } });
}
async function removeFriend(userId: string): Promise<void> {
return await fetchWithApi(`/me/friends/${userId}`, { method: "DELETE" });
}
async function fetchMessages(channelId: string, options?: { amount?: number, offset?: number }): Promise<MessageResponse[] | undefined> {
return await fetchWithApi(`/channels/${channelId}/messages`, { query: { amount: options?.amount ?? 100, offset: options?.offset ?? 0 } });
@ -59,6 +71,9 @@ export const useApi = () => {
fetchMember,
fetchUsers,
fetchUser,
fetchFriends,
addFriend,
removeFriend,
fetchMessages,
fetchMessage,
fetchInstanceStats,

View file

@ -106,7 +106,7 @@ export const useAuth = () => {
}
return {
accessToken,
clearAuth,
register,
login,
logout,

View file

@ -10,7 +10,7 @@
</div>
<div id = "page-content">
<div id="left-column">
<NuxtLink id="home-button" href="/">
<NuxtLink id="home-button" href="/me">
<img class="sidebar-icon" src="/public/icon.svg"/>
</NuxtLink>
<div id="servers-list">

View file

@ -1,23 +1,11 @@
<template>
<NuxtLayout>
<div id="left-bar">
</div>
<div id="middle-bar">
<h1>
Welcome to gorb :3
</h1>
<p>
Click on a guild to the left to view a guild.
<br>
Or click the button in the bottom left to join a guild.
</p>
</div>
<div id="right-bar">
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
await navigateTo("/me/", { replace: true })
definePageMeta({
layout: "client"

15
pages/me/[userId].vue Normal file
View file

@ -0,0 +1,15 @@
<template>
<NuxtLayout name="client">
<DirectMessagesSidebar />
<MessageArea channel-url="channels/01970e8c-a09c-76a0-9c98-80a43364bea7"/> <!-- currently just links to the default channel -->
</NuxtLayout>
</template>
<script lang="ts" setup>
import DirectMessagesSidebar from '~/components/Me/DirectMessagesSidebar.vue';
</script>
<style>
</style>

56
pages/me/friends.vue Normal file
View file

@ -0,0 +1,56 @@
<template>
<NuxtLayout name="client">
<DirectMessagesSidebar />
<div :id="$style['page-content']">
<div :id="$style['navigation-bar']">
<Button :text="`All Friends ${friends?.length}`" variant="neutral" :callback="() => updateFilter('all')" />
<Button :text="`Online ${0}`" variant="neutral" :callback="() => updateFilter('online')" />
<Button :text="`Pending ${0}`" variant="neutral" :callback="() => updateFilter('pending')" />
<Button text="Add Friend" variant="normal" :callback="() => updateFilter('add')" />
</div>
<div>
<AddFriend v-if="filter == 'add'"></AddFriend>
<FriendsList v-else :variant="filter"></FriendsList>
</div>
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import DirectMessagesSidebar from '~/components/Me/DirectMessagesSidebar.vue';
import Button from '~/components/UserInterface/Button.vue';
import AddFriend from '~/components/Me/AddFriend.vue';
import FriendsList from '~/components/Me/FriendsList.vue';
const { fetchFriends } = useApi();
let filter = ref("all");
const friends = await fetchFriends()
function updateFilter(newFilter: string) {
filter.value = newFilter;
}
</script>
<style module>
#page-content {
display: flex;
flex-direction: column;
flex-grow: 1;
margin: .75em;
}
#navigation-bar {
display: flex;
align-items: left;
text-align: left;
flex-direction: row;
gap: .5em;
}
</style>

13
pages/me/index.vue Normal file
View file

@ -0,0 +1,13 @@
<template>
<NuxtLayout name="client">
<DirectMessagesSidebar />
</NuxtLayout>
</template>
<script lang="ts" setup>
import DirectMessagesSidebar from '~/components/Me/DirectMessagesSidebar.vue';
</script>
<style>
</style>

View file

@ -18,7 +18,7 @@
</h3>
</div>
<div id="channels-list">
<Channel v-for="channel of channels" :name="channel.name"
<ChannelEntry v-for="channel of channels" :name="channel.name"
:uuid="channel.uuid" :current-uuid="(route.params.channelId as string)"
:href="`/servers/${route.params.serverId}/channels/${channel.uuid}`" />
</div>
@ -33,6 +33,7 @@
</template>
<script lang="ts" setup>
import ChannelEntry from "~/components/Guild/ChannelEntry.vue";
const route = useRoute();
@ -46,7 +47,6 @@ const channel = ref<ChannelResponse | undefined>();
const showInvitePopup = ref(false);
import UserPopup from "~/components/UserPopup.vue";
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse } from "~/types/interfaces";
//const servers = await fetchWithApi("/servers") as { uuid: string, name: string, description: string }[];
@ -91,7 +91,8 @@ function handleMemberClick(member: GuildMemberResponse) {
}
#members-container {
width: 15rem;
min-width: 15rem;
max-width: 15rem;
border-left: 1px solid var(--padding-color);
background: var(--optional-member-list-background);
}

View file

@ -8,7 +8,7 @@
<Icon class="back-button" size="2em" name="lucide:circle-arrow-left" alt="Back"></Icon>
</span>
</p>
<span class="spacer"></span>
<VerticalSpacer />
<!-- categories and dynamic settings pages -->
<div v-for="category in categories" :key="category.displayName">
@ -17,13 +17,13 @@
:class="{ 'sidebar-focus': selectedPage === page.displayName }">
{{ page.displayName }}
</li>
<span class="spacer"></span>
<VerticalSpacer />
</div>
<p>
<Button text="Log Out" :callback=logout variant="scary"></Button>
</p>
<span class="spacer"></span>
<VerticalSpacer />
<p id="links-and-socials">
<NuxtLink href="https://git.gorb.app/gorb/frontend" title="Source"><Icon name="lucide:git-branch-plus" /></NuxtLink>
@ -46,6 +46,9 @@
<script lang="ts" setup>
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import Button from '~/components/UserInterface/Button.vue';
const { logout } = useAuth()
const appConfig = useRuntimeConfig()
@ -196,13 +199,6 @@ onMounted(() => {
margin-right: 0.2em;
}
.spacer {
height: 0.2dvh;
display: block;
margin: 0.8dvh 1dvw;
background-color: var(--padding-color);
}
/* applies to child pages too */
:deep(.subtitle) {
display: block;

View file

@ -1,6 +1,7 @@
:root {
--text-color: #f0e5e0;
--secondary-text-color: #e8e0db;
--reply-text-color: #969696;
--chat-background-color: #2f2e2d;
--chat-highlighted-background-color: #3f3b38;

View file

@ -1,6 +1,7 @@
:root {
--text-color: #f7eee8;
--secondary-text-color: #f0e8e4;
--reply-text-color: #969696;
--chat-background-color: #1f1e1d;
--chat-highlighted-background-color: #2f2b28;

View file

@ -2,6 +2,7 @@
:root {
--text-color: #161518;
--secondary-text-color: #2b2930;
--reply-text-color: #969696;
--chat-background-color: #80808000;
--chat-highlighted-background-color: #ffffff20;

View file

@ -1,6 +1,7 @@
:root {
--text-color: #170f08;
--secondary-text-color: #2f2b28;
--reply-text-color: #969696;
--chat-background-color: #f0ebe8;
--chat-highlighted-background-color: #e8e4e0;

View file

@ -1,6 +1,7 @@
:root {
--text-color: #161518;
--secondary-text-color: #2b2930;
--reply-text-color: #969696;
--chat-background-color: #80808000;
--chat-highlighted-background-color: #ffffff20;

View file

@ -62,7 +62,8 @@ export interface UserResponse {
pronouns: string | null,
about: string | null,
email?: string,
email_verified?: boolean
email_verified?: boolean,
friends_since: string | null,
}
export interface StatsResponse {

View file

@ -18,7 +18,7 @@ export default async <T>(path: string, options: NitroFetchOptions<string> = {})
return;
}
console.log("path:", path)
const { revoke, refresh } = useAuth();
const { clearAuth, refresh } = useAuth();
let headers: HeadersInit = {};
@ -61,8 +61,7 @@ export default async <T>(path: string, options: NitroFetchOptions<string> = {})
if (error?.response?.status === 401) {
console.log("Refresh returned 401");
reauthFailed = true;
console.log("Revoking");
await revoke();
await clearAuth()
console.log("Redirecting to login");
await navigateTo("/login");
console.log("redirected");