Merge pull request 'guild-settings' (#35) from guild-settings into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful

Reviewed-on: #35
This commit is contained in:
SauceyRed 2025-07-13 02:26:37 +00:00
commit 2299d3a17a
12 changed files with 573 additions and 76 deletions

View file

@ -25,6 +25,13 @@ onMounted(() => {
if (e.target instanceof HTMLElement && e.target.classList.contains("message-text") && e.target.contentEditable) {
e.target.contentEditable = "false";
}
const destroyOnClick = document.getElementsByClassName("destroy-on-click");
for (const element of destroyOnClick) {
const closest = (e.target as HTMLElement).closest(".destroy-on-click");
if (element != closest) {
unrender(element);
}
}
});
document.addEventListener("keyup", (e) => {
const messageReply = document.getElementById("message-reply") as HTMLDivElement;

13
components/Banner.vue Normal file
View file

@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

46
components/Dropdown.vue Normal file
View file

@ -0,0 +1,46 @@
<template>
<div class="dropdown-body">
<div v-for="option of props.options" class="dropdown-option">
<button class="dropdown-button" :data-value="option.value" @click.prevent="option.callback" tabindex="0">{{ option.name }}</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { DropdownOption } from '~/types/interfaces';
const props = defineProps<{ options: DropdownOption[] }>();
</script>
<style scoped>
.dropdown-body {
position: absolute;
z-index: 100;
left: 4dvw;
bottom: 4dvh;
background-color: var(--chat-background-color);
width: 8rem;
display: flex;
flex-direction: column;
}
.dropdown-option {
border: .09rem solid rgb(70, 70, 70);
}
.dropdown-button {
padding-top: .5dvh;
padding-bottom: .5dvh;
color: var(--text-color);
background-color: transparent;
width: 100%;
border: none;
}
.dropdown-button:hover {
background-color: var(--padding-color);
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div id="guild-options-menu" class="destroy-on-click">
<div v-for="setting of settings" class="guild-option" tabindex="0">
<button class="guild-option-button" @click="setting.action" tabindex="0">{{ setting.name }}</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { render } from 'vue';
import InviteModal from './InviteModal.vue';
const settings = [
{ name: "Invite", icon: "lucide:letter", action: openInviteModal }
]
function openInviteModal() {
const div = document.createElement("div");
const guildId = useRoute().params.serverId as string;
console.log("guild id:", guildId);
const inviteModal = h(InviteModal, { guildId });
document.body.appendChild(div);
render(inviteModal, div);
}
</script>
<style>
#guild-options-menu {
display: flex;
flex-direction: column;
position: relative;
background-color: var(--chat-background-color);
top: 8dvh;
z-index: 10;
width: 100%;
position: absolute;
}
.guild-option {
display: flex;
justify-content: center;
align-items: center;
height: 2em;
box-sizing: border-box;
}
.guild-option:hover {
background-color: var(--padding-color);
}
.guild-option-button {
border: 0;
background-color: transparent;
color: var(--main-text-color);
height: 100%;
width: 100%;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<Modal v-bind="props" :title="props.title || 'Create an invite'">
<div v-if="invite" id="invite-body">
<div id="invite-label">{{ invite }}</div>
<div id="invite-buttons">
<Button text="Copy as link" variant="neutral" :callback="() => copyInvite('link')" />
<Button text="Copy as code" variant="neutral" :callback="() => copyInvite('code')" />
</div>
</div>
<div v-else>
<Button text="Generate Invite" variant="normal" :callback="generateInvite">Generate Invite</Button>
</div>
</Modal>
</template>
<script lang="ts" setup>
import type { InviteResponse, ModalProps } from '~/types/interfaces';
import Button from './UserInterface/Button.vue';
const props = defineProps<ModalProps & { guildId: string }>();
const invite = ref<string>();
async function generateInvite(): Promise<void> {
const chars = "ABCDEFGHIJKLMNOQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
let randCode = "";
for (let i = 0; i < 6; i++) {
randCode += chars[Math.floor(Math.random() * chars.length)];
}
const createdInvite: InviteResponse | undefined = await fetchWithApi(
`/guilds/${props.guildId}/invites`,
{ method: "POST", body: { custom_id: randCode } }
);
invite.value = createdInvite?.id;
return;
}
function copyInvite(type: "link" | "code") {
if (!invite.value) return;
if (type == "link") {
const inviteUrl = URL.parse(`invite/${invite.value}`, `${window.location.protocol}//${window.location.host}`);
if (inviteUrl) {
navigator.clipboard.writeText(inviteUrl.href);
}
} else {
navigator.clipboard.writeText(invite.value);
}
}
</script>
<style scoped>
#invite-body, #invite-buttons {
display: flex;
gap: 1em;
}
#invite-body {
flex-direction: column;
}
#invite-label {
text-align: center;
color: aquamarine;
}
</style>

84
components/Modal.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<dialog ref="dialog" class="modal" :class="props.obscure ? 'modal-obscure' : 'modal-regular'">
<span class="modal-exit-button-container" style="position: absolute; right: 2em; top: .2em; width: .5em; height: .5em;">
<Button text="X" variant="neutral" :callback="() => dialog?.remove()" />
</span>
<div class="modal-content">
<h1 class="modal-title">{{ title }}</h1>
<slot />
</div>
</dialog>
</template>
<script lang="ts" setup>
import type { ModalProps } from '~/types/interfaces';
import Button from './UserInterface/Button.vue';
const props = defineProps<ModalProps>();
const dialog = ref<HTMLDialogElement>();
console.log("props:", props);
onMounted(() => {
if (dialog.value) {
dialog.value.showModal();
if (props.onClose) {
dialog.value.addEventListener("close", props.onClose);
}
if (props.onCancel) {
dialog.value.addEventListener("cancel", props.onCancel);
}
}
});
</script>
<style>
.modal {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 1em;
opacity: 100%;
padding: 1%;
background-color: var(--sidebar-highlighted-background-color);
color: var(--text-color);
overflow: hidden;
}
.modal-regular::backdrop {
background-color: var(--chat-background-color);
opacity: 0%;
}
.modal-obscure::backdrop {
background-color: var(--chat-background-color);
opacity: 80%;
}
.modal-top-container {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
.modal-title {
font-size: 1.5rem;
padding: 0;
}
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
margin: 1em;
width: 100%;
}
</style>

View file

@ -1,40 +0,0 @@
<template>
<div id="invite-popup">
<div v-if="invite">
<p>{{ invite }}</p>
<button @click="copyInvite">Copy Link</button>
</div>
<div v-else>
<button @click="generateInvite">Generate Invite</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { InviteResponse } from '~/types/interfaces';
const invite = ref<string>();
const route = useRoute();
async function generateInvite(): Promise<void> {
const createdInvite: InviteResponse | undefined = await fetchWithApi(
`/guilds/${route.params.serverId}/invites`,
{ method: "POST", body: { custom_id: "oijewfoiewf" } }
);
invite.value = createdInvite?.id;
return;
}
function copyInvite() {
const inviteUrl = URL.parse(`invite/${invite.value}`, `${window.location.protocol}//${window.location.host}`);
navigator.clipboard.writeText(inviteUrl!.href);
}
</script>
<style>
</style>

View file

@ -53,6 +53,18 @@ export const useApi = () => {
return await fetchWithApi(`/channels/${channelId}/messages/${messageId}`);
}
async function createGuild(name: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/guilds`, { method: "POST", body: { name } });
}
async function joinGuild(invite: string): Promise<GuildResponse> {
return await fetchWithApi(`/invites/${invite}`, { method: "POST" }) as GuildResponse;
}
async function createChannel(guildId: string, name: string, description?: string): Promise<void> {
return await fetchWithApi(`/guilds/${guildId}/channels`, { method: "POST", body: { name, description } });
}
async function fetchInstanceStats(apiBase: string): Promise<StatsResponse> {
return await $fetch(`${apiBase}/stats`, { method: "GET" });
}
@ -76,6 +88,9 @@ export const useApi = () => {
removeFriend,
fetchMessages,
fetchMessage,
createGuild,
joinGuild,
createChannel,
fetchInstanceStats,
sendVerificationEmail
}

View file

@ -10,18 +10,27 @@
</div>
<div id = "page-content">
<div id="left-column">
<NuxtLink id="home-button" href="/me">
<img class="sidebar-icon" src="/public/icon.svg"/>
</NuxtLink>
<div id="servers-list">
<NuxtLink v-for="guild of guilds" :href="`/servers/${guild.uuid}`">
<img v-if="guild.icon" class="sidebar-icon" :src="guild.icon" :alt="guild.name"/>
<Icon v-else name="lucide:server" class="sidebar-icon white" :alt="guild.name" />
<div id="left-column-top">
<NuxtLink id="home-button" href="/me">
<img class="sidebar-icon" src="/public/icon.svg"/>
</NuxtLink>
<div id="servers-list">
<NuxtLink v-for="guild of guilds" :href="`/servers/${guild.uuid}`">
<img v-if="guild.icon" class="sidebar-icon" :src="guild.icon" :alt="guild.name"/>
<Icon v-else name="lucide:server" class="sidebar-icon white" :alt="guild.name" />
</NuxtLink>
</div>
</div>
<div id="left-column-bottom">
<div ref="createButtonContainer">
<button id="create-button" @click.prevent="createDropdown">
<Icon id="create-icon" name="lucide:square-plus" />
</button>
</div>
<NuxtLink id="settings-menu" href="/settings">
<Icon name="lucide:settings" class="sidebar-icon" alt="Settings menu" />
</NuxtLink>
</div>
<NuxtLink id="settings-menu" href="/settings">
<Icon name="lucide:settings" class="sidebar-icon" alt="Settings menu" />
</NuxtLink>
</div>
<slot />
</div>
@ -29,11 +38,165 @@
</template>
<script lang="ts" setup>
import { render } from 'vue';
import Dropdown from '~/components/Dropdown.vue';
import Modal from '~/components/Modal.vue';
import Button from '~/components/UserInterface/Button.vue';
import type { GuildResponse } from '~/types/interfaces';
const loading = useState("loading", () => false);
const createButtonContainer = ref<HTMLButtonElement>();
const api = useApi();
const options = [
{ name: "Join", value: "join", callback: async () => {
console.log("join guild!");
const div = document.createElement("div");
const guildJoinModal = h(Modal, {
title: "Join Guild",
id: "guild-join-modal",
onClose: () => {
unrender(div);
},
onCancel: () => {
unrender(div);
},
style: "height: 20dvh; width: 15dvw"
},
[
h("input", {
id: "guild-invite-input",
type: "text",
placeholder: "oyqICZ",
}),
h(Button, {
text: "Join",
variant: "normal",
callback: async () => {
const input = document.getElementById("guild-invite-input") as HTMLInputElement;
const invite = input.value;
if (invite.length == 6) {
try {
const joinedGuild = await api.joinGuild(invite);
guilds?.push(joinedGuild);
return await navigateTo(`/servers/${joinedGuild.uuid}`);
} catch (error) {
alert(`Couldn't use invite: ${error}`);
}
}
}
})
]);
document.body.appendChild(div);
render(guildJoinModal, div);
}
},
{ name: "Create", value: "create", callback: async () => {
console.log("create guild");
const user = await useAuth().getUser();
const div = document.createElement("div");
const guildCreateModal = h(Modal, {
title: "Create a Guild",
id: "guild-join-modal",
onClose: () => {
unrender(div);
},
onCancel: () => {
unrender(div);
},
style: "height: 20dvh; width: 15dvw;"
},
[
h("input", {
id: "guild-name-input",
type: "text",
placeholder: `${user?.display_name || user?.username}'s Awesome Bouncy Castle'`,
style: "width: 100%"
}),
h(Button, {
text: "Create!",
variant: "normal",
callback: async () => {
const input = document.getElementById("guild-name-input") as HTMLInputElement;
const name = input.value;
try {
const guild = (await api.createGuild(name)) as GuildResponse;
await api.createChannel(guild.uuid, "general");
} catch (error) {
alert(`Couldn't create guild: ${error}`);
}
}
})
]);
document.body.appendChild(div);
render(guildCreateModal, div);
}
}
];
const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
//const servers = await fetchWithApi("/servers") as { uuid: string, name: string, description: string }[];
//console.log("servers:", servers);
const members = [
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
},
{
id: "3287484395",
displayName: "SauceyRed"
}
];
function createDropdown() {
const dropdown = h(Dropdown, { options });
const div = document.createElement("div");
div.classList.add("dropdown", "destroy-on-click");
if (createButtonContainer.value) {
createButtonContainer.value.appendChild(div);
} else {
document.body.appendChild(div);
}
render(dropdown, div);
div.addEventListener("keyup", (e) => {
if (e.key == "Escape") {
unrender(div);
}
});
div.focus();
}
</script>
<style>
@ -80,6 +243,8 @@ const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
#left-column {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: .75em;
padding-left: .25em;
padding-right: .25em;
@ -89,6 +254,31 @@ const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
background-color: var(--sidebar-background-color);
}
#left-column-top, #left-column-bottom {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1.5dvh;
overflow-y: scroll;
}
#left-column-bottom {
padding-top: 1dvh;
border-top: 1px solid var(--padding-color);
}
#middle-left-column {
padding-left: 1dvw;
padding-right: 1dvw;
border-right: 1px solid var(--padding-color);
}
#home-button {
border-bottom: 1px solid var(--padding-color);
padding-bottom: 1dvh;
}
#servers-list {
display: flex;
flex-direction: column;
@ -97,6 +287,20 @@ const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
padding-top: .5em;
}
#create-button {
color: var(--primary-color);
background-color: transparent;
border: none;
cursor: pointer;
font-size: 2rem;
padding: 0;
display: inline-block;
}
#create-icon {
float: left;
}
#middle-left-column {
padding-left: .25em;
padding-right: .25em;
@ -120,9 +324,6 @@ const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
}
#settings-menu {
position: absolute;
bottom: .25em;
color: var(--primary-color)
}

View file

@ -1,21 +1,12 @@
<template>
<NuxtLayout name="client">
<div id="middle-left-column" class="main-grid-row">
<div id="server-title">
<h3>
{{ server?.name }}
<span>
<button @click="showGuildSettings">
<Icon name="lucide:settings" />
</button>
</span>
<span>
<button @click="toggleInvitePopup">
<Icon name="lucide:share-2" />
</button>
</span>
<InvitePopup v-if="showInvitePopup" />
</h3>
<div id="server-name-container">
<span id="server-name">{{ server?.name }}</span>
<button id="server-settings-button" @click="toggleGuildSettings">
<Icon id="server-settings-icon" name="lucide:chevron-down" />
</button>
<GuildOptionsMenu v-if="showGuildSettings" />
</div>
<div id="channels-list">
<ChannelEntry v-for="channel of channels" :name="channel.name"
@ -45,7 +36,10 @@ const server = ref<GuildResponse | undefined>();
const channels = ref<ChannelResponse[] | undefined>();
const channel = ref<ChannelResponse | undefined>();
const members = ref<GuildMemberResponse[]>();
const showInvitePopup = ref(false);
const showGuildSettings = ref(false);
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse } from "~/types/interfaces";
@ -53,23 +47,34 @@ import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageRespon
//console.log("channelid: servers:", servers);
const { fetchMembers } = useApi();
const members = await fetchMembers(route.params.serverId as string);
onMounted(async () => {
console.log("channelid: set loading to true");
console.log("mounting");
const guildUrl = `guilds/${route.params.serverId}`;
server.value = await fetchWithApi(guildUrl);
await setArrayVariables();
});
onActivated(async () => {
console.log("activating");
const guildUrl = `guilds/${route.params.serverId}`;
server.value = await fetchWithApi(guildUrl);
await setArrayVariables();
});
async function setArrayVariables() {
members.value = await fetchMembers(route.params.serverId as string);
const guildUrl = `guilds/${route.params.serverId}`;
channels.value = await fetchWithApi(`${guildUrl}/channels`);
console.log("channels:", channels.value);
channel.value = await fetchWithApi(`/channels/${route.params.channelId}`);
console.log("channel:", channel.value);
}
console.log("channelid: channel:", channel);
console.log("channelid: set loading to false");
});
function showGuildSettings() { }
function toggleGuildSettings(e: Event) {
e.preventDefault();
showGuildSettings.value = !showGuildSettings.value;
}
function toggleInvitePopup(e: Event) {
e.preventDefault();
@ -81,7 +86,6 @@ function handleMemberClick(member: GuildMemberResponse) {
</script>
<style>
#middle-left-column {
padding-left: .5em;
padding-right: .5em;
@ -136,4 +140,23 @@ function handleMemberClick(member: GuildMemberResponse) {
text-overflow: ellipsis;
}
#server-name-container {
padding-top: 3dvh;
padding-bottom: 3dvh;
display: flex;
justify-content: center;
position: relative;
}
#server-name {
font-size: 1.5em;
}
#server-settings-button {
background-color: transparent;
font-size: 1em;
color: white;
border: none;
padding: 0%;
}
</style>

View file

@ -86,6 +86,19 @@ export interface ScrollPosition {
offsetLeft: number
}
export interface DropdownOption {
name: string,
value: string | number,
callback: () => void
}
export interface ModalProps {
title?: string,
obscure?: boolean,
onClose?: () => void,
onCancel?: () => void
}
export interface ContextMenuItem {
name: string,
callback: (...args: any[]) => any;

6
utils/unrender.ts Normal file
View file

@ -0,0 +1,6 @@
import { render } from "vue";
export default (div: Element) => {
render(null, div);
div.remove();
}