Merge branch 'main' into improved-context-menu

This commit is contained in:
SauceyRed 2025-07-30 17:01:44 +02:00
commit ecfa85c0ac
Signed by: sauceyred
GPG key ID: 2BF92EB6D8A5CCA7
55 changed files with 1030 additions and 297 deletions

44
components/Avatar.vue Normal file
View file

@ -0,0 +1,44 @@
<template>
<NuxtImg v-if="displayAvatar"
class="display-avatar"
:src="displayAvatar"
:alt="displayName" />
<Icon v-else
name="lucide:user"
:alt="displayName" />
</template>
<script lang="ts" setup>
import { NuxtImg } from '#components';
import type { GuildMemberResponse, UserResponse } from '~/types/interfaces';
const props = defineProps<{
user?: UserResponse,
member?: GuildMemberResponse,
}>();
let displayName: string
let displayAvatar: string | null
const user = props.user || props.member?.user
if (user) {
displayName = getDisplayName(user, props.member)
if (user.avatar) {
displayAvatar = user.avatar
} else if (!isCanvasBlocked()){
displayAvatar = generateDefaultIcon(displayName, user.uuid)
} else {
displayAvatar = null
}
}
</script>
<style scoped>
.display-avatar {
border-radius: var(--pfp-radius);
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<div v-if="isCurrentChannel" class="channel-list-link-container rounded-corners current-channel" tabindex="0">
<div v-if="isCurrentChannel" class="channel-list-link-container rounded-corners current-channel" tabindex="0" :title="props.name">
<NuxtLink class="channel-list-link" :href="props.href" tabindex="-1">
# {{ props.name }}
</NuxtLink>
</div>
<div v-else class="channel-list-link-container rounded-corners" tabindex="0">
<div v-else class="channel-list-link-container rounded-corners" tabindex="0" :title="props.name">
<NuxtLink class="channel-list-link" :href="props.href" tabindex="-1">
# {{ props.name }}
</NuxtLink>
@ -25,6 +25,8 @@ const isCurrentChannel = props.uuid == props.currentUuid;
color: inherit;
padding-left: .25em;
padding-right: .25em;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-list-link-container {

View file

@ -1,8 +1,7 @@
<template>
<div class="member-item" @click="togglePopup" @blur="hidePopup" tabindex="0">
<img v-if="props.member.user.avatar" class="member-avatar" :src="props.member.user.avatar" :alt="props.member.user.display_name ?? props.member.user.username" />
<Icon v-else class="member-avatar" name="lucide:user" />
<span class="member-display-name">{{ props.member.user.display_name || props.member.user.username }}</span>
<Avatar :member="props.member" class="member-avatar"/>
<span class="member-display-name">{{ getDisplayName(props.member.user, props.member) }}</span>
<UserPopup v-if="isPopupVisible" :user="props.member.user" id="profile-popup" />
</div>
</template>

View file

@ -1,27 +1,30 @@
<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}`"/>
<ResizableSidebar width="14rem" min-width="8rem" max-width="30rem" border-sides="right" local-storage-name="middleLeftColumn">
<div id="middle-left-column">
<div id="friend-sidebar">
<div>
<h3>Direct Messages</h3>
</div>
<VerticalSpacer />
<NuxtLink class="user-item" :href="`/me`" 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"
:href="`/me/${user.uuid}`"/>
</div>
</div>
</div>
</div>
</ResizableSidebar>
</template>
<script lang="ts" setup>
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import ResizableSidebar from '../UserInterface/ResizableSidebar.vue';
const { fetchFriends } = useApi();

View file

@ -17,7 +17,7 @@
</div>
<div v-else>
<UserEntry v-for="user of friends" :user="user" :name="user.display_name || user.username"
<UserEntry v-for="user of friends" :user="user" :name="getDisplayName(user)"
:href="`/me/${user.uuid}`"/>
</div>
</div>
@ -26,7 +26,7 @@
<script lang="ts" setup>
const { fetchFriends } = useApi();
const friends = await fetchFriends()
const friends = sortUsers(await fetchFriends())
const props = defineProps<{
variant: string

View file

@ -8,14 +8,10 @@
viewBox="0 0 150 87.5" version="1.1" id="svg1"
style="overflow: visible;">
<defs id="defs1" />
<defs
id="defs1" />
<g
id="layer1"
<g id="layer1"
transform="translate(40,-35)">
<g
id="g3"
transform="translate(-35,-20)">
<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"
@ -28,16 +24,17 @@
</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" />
<MessageReply v-if="props.replyMessage" :id="props.message.uuid"
:author="getDisplayName(props.replyMessage.user)"
:text="props.replyMessage?.message"
:reply-id="props.replyMessage.uuid" max-width="reply" />
<div class="left-column">
<img v-if="props.img" class="message-author-avatar" :src="props.img" :alt="author?.display_name || author?.username" />
<Icon v-else name="lucide:user" class="message-author-avatar" />
<Avatar :user="props.author" class="message-author-avatar"/>
</div>
<div class="message-data">
<div class="message-metadata">
<span class="message-author-username" tabindex="0">
{{ author?.display_name || author?.username }}
<span class="message-author-username" tabindex="0" :style="`color: ${props.authorColor}`">
{{ getDisplayName(props.author) }}
</span>
<span class="message-date" :title="date.toString()">
<span v-if="getDayDifference(date, currentDate) === 1">Yesterday at</span>
@ -233,7 +230,6 @@ function getDayDifference(date1: Date, date2: Date) {
.message-author-avatar {
width: 100%;
border-radius: 50%;
}
.left-column {

View file

@ -1,12 +1,13 @@
<template>
<div id="message-area">
<div id="messages" ref="messagesElement">
<Message v-for="(message, i) of messages" :username="message.user.display_name ?? message.user.username"
<Message v-for="(message, i) of messages" :username="getDisplayName(message.user)"
:text="message.message" :timestamp="messageTimestamps[message.uuid]" :img="message.user.avatar"
:format="timeFormat" :type="messagesType[message.uuid]"
:margin-bottom="(messages[i + 1] && messagesType[messages[i + 1].uuid] == 'normal') ?? false"
:last="i == messages.length - 1" :message-id="message.uuid" :author="message.user" :me="me"
:message="message" :is-reply="message.reply_to"
:author-color="`${generateIrcColor(message.user.uuid)}`"
:reply-message="message.reply_to ? getReplyMessage(message.reply_to) : undefined" />
</div>
<div id="message-box" class="rounded-corners">
@ -41,6 +42,7 @@
<script lang="ts" setup>
import type { MessageResponse, ScrollPosition, UserResponse } from '~/types/interfaces';
import scrollToBottom from '~/utils/scrollToBottom';
import { generateIrcColor } from '#imports';
const props = defineProps<{ channelUrl: string, amount?: number, offset?: number }>();

View file

@ -10,6 +10,7 @@
<script lang="ts" setup>
import Cropper from 'cropperjs';
import Button from '../UserInterface/Button.vue';
const props = defineProps({
imageSrc: String,

View file

@ -32,13 +32,13 @@
<script lang="ts" setup>
import RadioButtons from '~/components/UserInterface/RadioButtons.vue';
import type { TimeFormat } from '~/types/settings';
import loadPreferredTheme from '~/utils/loadPreferredTheme';
import settingSave from '~/utils/settingSave';
const runtimeConfig = useRuntimeConfig()
const defaultThemes = runtimeConfig.public.defaultThemes
const baseURL = runtimeConfig.app.baseURL;
const timeFormatTextStrings = ["Auto", "12-Hour", "24-Hour"]
let themeLinkElement: HTMLLinkElement | null = null;
const themes: Array<Theme> = []
@ -51,20 +51,8 @@ interface Theme {
}
function changeTheme(id: string, url: string) {
if (themeLinkElement && themeLinkElement.getAttribute('href') === `${baseURL}themes/${url}`) {
return;
}
settingSave("selectedThemeId", id)
// if the theme didn't originally load for some reason, create it
if (!themeLinkElement) {
themeLinkElement = document.createElement('link');
themeLinkElement.rel = 'stylesheet';
document.head.appendChild(themeLinkElement);
}
themeLinkElement.href = `${baseURL}themes/${url}`;
loadPreferredTheme()
}
async function fetchThemes() {

View file

@ -0,0 +1,11 @@
import Appearance from './Appearance.vue';
import Notifications from './Notifications.vue';
import Keybinds from './Keybinds.vue';
import Language from './Language.vue';
export {
Appearance,
Notifications,
Keybinds,
Language,
}

View file

@ -33,6 +33,7 @@
</template>
<script lang="ts" setup>
import CropPopup from '~/components/Popups/CropPopup.vue';
import UserPopup from '~/components/User/UserPopup.vue';
import Button from '~/components/UserInterface/Button.vue';
@ -41,7 +42,7 @@ import type { UserResponse } from '~/types/interfaces';
let newPfpFile: File;
const isCropPopupVisible = ref(false);
const cropImageSrc = ref("")
;
const { fetchUser } = useAuth();
const user: UserResponse | undefined = await fetchUser()

View file

@ -0,0 +1,13 @@
import Profile from './Profile.vue';
import Account from './Account.vue';
import Privacy from './Privacy.vue';
import Devices from './Devices.vue';
import Connections from './Connections.vue';
export {
Profile,
Account,
Privacy,
Devices,
Connections,
}

View file

@ -1,8 +1,8 @@
<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>
<Avatar :user="props.user" class="user-avatar"/>
<span class="user-display-name">{{ getDisplayName(props.user) }}</span>
</NuxtLink>
</template>
@ -12,6 +12,7 @@ import type { UserResponse } from '~/types/interfaces';
const props = defineProps<{
user: UserResponse
}>();
</script>
<style>
@ -33,8 +34,15 @@ const props = defineProps<{
}
.user-avatar {
width: 2.3em;
height: 2.3em;
border-radius: 50%;
min-width: 2.3em;
max-width: 2.3em;
min-width: 2.3em;
max-height: 2.3em;
}
.user-display-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -1,12 +1,11 @@
<template>
<div id="profile-popup">
<img v-if="props.user.avatar" id="avatar" :src="props.user.avatar" alt="profile avatar">
<Icon v-else id="avatar" name="lucide:user" />
<Avatar :user="props.user" id="avatar"/>
<div id="cover-color"></div>
<div id="main-body">
<p id="display-name">
<strong>{{ props.user.display_name }}</strong>
<strong>{{ getDisplayName(props.user) }}</strong>
</p>
<p id="username-and-pronouns">
{{ props.user.username }}
@ -22,8 +21,6 @@
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const { fetchMembers } = useApi();
const props = defineProps<{
user: UserResponse
}>();

View file

@ -1,14 +1,14 @@
<template>
<div @click="props.callback()" class="button" :class="props.variant + '-button'">
<button @click="props.callback ? props.callback() : null" class="button" :class="props.variant + '-button'">
{{ props.text }}
</div>
</button>
</template>
<script lang="ts" setup>
const props = defineProps<{
text: string,
callback: CallableFunction,
callback?: CallableFunction,
variant?: "normal" | "scary" | "neutral",
}>();
@ -28,6 +28,8 @@ const props = defineProps<{
border-radius: 0.7rem;
text-decoration: none;
display: inline-block;
border: none;
}
.button:hover {

View file

@ -9,13 +9,13 @@
<script lang="ts" setup>
import type { ContextMenuInterface, ContextMenuItem } from '~/types/interfaces';
const props = defineProps<{ menuItems: ContextMenuItem[], cursorX: number, cursorY: number }>();
const props = defineProps<{ menuItems: ContextMenuItem[], pointerX: number, pointerY: number }>();
onMounted(() => {
const contextMenu = document.getElementById("context-menu");
if (contextMenu) {
contextMenu.style.left = props.cursorX.toString() + "px";
contextMenu.style.top = props.cursorY.toString() + "px";
contextMenu.style.left = props.pointerX.toString() + "px";
contextMenu.style.top = props.pointerY.toString() + "px";
}
});

View file

@ -0,0 +1,140 @@
<template>
<div ref="resizableSidebar" class="resizable-sidebar"
:style="{
'width': storedWidth ? `${storedWidth}px` : props.width,
'min-width': props.minWidth,
'max-width': props.maxWidth,
'border': props.borderSides == 'all' ? borderStyling : undefined,
'border-top': props.borderSides?.includes('top') ? borderStyling : undefined,
'border-bottom': props.borderSides?.includes('bottom') ? borderStyling : undefined,
}">
<div v-if="props.borderSides != 'right'" class="width-resizer-bar">
<div ref="widthResizer" class="width-resizer"></div>
</div>
<div class="sidebar-content">
<slot />
</div>
<div v-if="props.borderSides == 'right'" class="width-resizer-bar">
<div ref="widthResizer" class="width-resizer"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ContextMenuItem } from '~/types/interfaces';
const props = defineProps<{ width?: string, minWidth: string, maxWidth: string, borderSides: "all" | "top" | "right" | "bottom" | "left" | ("top" | "right" | "bottom" | "left")[], localStorageName?: string }>();
const borderStyling = ".1rem solid var(--padding-color)";
const resizableSidebar = ref<HTMLDivElement>();
const widthResizer = ref<HTMLDivElement>();
const storedWidth = ref<number>();
const menuItems: ContextMenuItem[] = [
{ name: "Reset", callback: () => { resizableSidebar.value!.style.width = props.width ?? props.minWidth } }
]
onMounted(() => {
loadStoredWidth();
if (resizableSidebar.value && widthResizer.value) {
widthResizer.value.addEventListener("pointerdown", (e) => {
e.preventDefault();
if (e.button == 2) {
createContextMenu(e, menuItems);
return
};
document.body.style.cursor = "ew-resize";
function handleMove(pointer: PointerEvent) {
if (resizableSidebar.value) {
console.log("moving");
console.log("pointer:", pointer);
console.log("width:", resizableSidebar.value.style.width);
let delta = 0;
if (props.borderSides == 'right') {
delta = resizableSidebar.value.getBoundingClientRect().left;
console.log("delta:", delta);
resizableSidebar.value.style.width = `${pointer.clientX - delta}px`;
} else {
delta = resizableSidebar.value.getBoundingClientRect().right;
console.log("delta:", delta);
resizableSidebar.value.style.width = `${delta - pointer.clientX}px`;
}
}
}
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", () => {
console.log("pointer up");
document.removeEventListener("pointermove", handleMove);
console.log("removed pointermove event listener");
document.body.style.cursor = "";
if (resizableSidebar.value && props.localStorageName) {
localStorage.setItem(props.localStorageName, resizableSidebar.value.style.width);
}
}, { once: true });
});
}
});
onActivated(() => {
console.log("[res] activated");
loadStoredWidth();
});
function loadStoredWidth() {
if (props.localStorageName) {
const storedWidthValue = localStorage.getItem(props.localStorageName);
if (storedWidthValue) {
storedWidth.value = parseInt(storedWidthValue) || undefined;
console.log("[res] loaded stored width");
}
}
}
</script>
<style>
.resizable-sidebar > * {
box-sizing: border-box;
}
.resizable-sidebar {
display: flex;
background: var(--optional-channel-list-background);
background-color: var(--sidebar-background-color);
height: 100%;
flex: 0 0 auto;
}
.width-resizer {
width: .5rem;
cursor: col-resize;
position: absolute;
height: 100%;
}
.width-resizer-bar {
display: flex;
justify-content: center;
position: relative;
height: 100%;
width: 1px;
background-color: var(--padding-color);
}
.sidebar-content {
width: 100%;
padding-left: .25em;
padding-right: .25em;
}
.sidebar-content > :first-child {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
}
</style>