dockerize, composte, and various improvements

This commit is contained in:
Zoe
2023-06-05 01:44:12 -05:00
parent cb6bfd8880
commit 99c385d211
56 changed files with 5907 additions and 8091 deletions

View File

@@ -1,3 +1,12 @@
<script setup>
defineProps({
danger: {
type: Boolean,
required: true
}
});
</script>
<template>
<li>
<button
@@ -7,12 +16,4 @@
<slot />
</button>
</li>
</template>
<script>
export default {
props: {
danger: Boolean,
},
};
</script>
</template>

View File

@@ -1,3 +1,16 @@
<script lang="ts" setup>
defineProps({
opened: {
type: Boolean,
required: true
},
inverted: {
type: Boolean,
default: false
}
});
</script>
<template>
<Transition name="pop-in">
<div
@@ -11,15 +24,6 @@
</Transition>
</template>
<script lang="ts">
export default {
props: {
opened: Boolean,
inverted: Boolean,
},
};
</script>
<style>
.dropdown {
transform-origin: top center;
@@ -43,11 +47,13 @@ export default {
@keyframes pop-in {
0% {
transform: scale(0);
transform: scale(0.7);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@@ -1,55 +1,51 @@
<script lang="ts">
<script lang="ts" setup>
import emojiJson from '~/assets/json/emoji.json';
import { ref } from 'vue';
export default {
emits: ['picked-emoji'],
data() {
return {
emojis: emojiJson,
categories: [
{ name: 'people', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('smileys')).concat(emojiJson.filter((e) => e.category.toLowerCase().includes('people'))) },
{ name: 'nature', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('nature')) },
{ name: 'food', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('food')) },
{ name: 'activities', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('activities')) },
{ name: 'travel', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('travel')) },
{ name: 'objects', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('objects')) },
{ name: 'symbols', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('symbols')) },
{ name: 'flags', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('flags')) }
],
};
},
methods: {
emojiStyles(emojiShortName: string | undefined, width: number) {
if (!emojiShortName) return;
defineEmits(['picked-emoji']);
const emojis = emojiJson;
const emoji = emojis.find((e) => e.short_names[0] === emojiShortName);
if (!emoji) return;
const sheet_x = (emoji.sheet_y * (width + 2));
const sheet_y = (emoji.sheet_x * (width + 2));
return {
background: 'url(/32.png)',
width: `${width}px`,
height: `${width}px`,
display: 'inline-block',
'background-position': `-${sheet_y + 1}px -${sheet_x + 1}px`,
'background-size': '6480% 6480%'
};
},
scrollTo(categoryName: string) {
const emojiPane = (this.$refs.emojiPane as HTMLDivElement);
const category = document.getElementById(categoryName);
if (!emojiPane || !category) return;
emojiPane.scrollTop = category.offsetTop - 550;
}
}
};
const emojiPane = ref(null as null | HTMLDivElement);
const categories = [
{ name: 'people', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('smileys')).concat(emojiJson.filter((e) => e.category.toLowerCase().includes('people'))) },
{ name: 'nature', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('nature')) },
{ name: 'food', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('food')) },
{ name: 'activities', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('activities')) },
{ name: 'travel', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('travel')) },
{ name: 'objects', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('objects')) },
{ name: 'symbols', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('symbols')) },
{ name: 'flags', emojis: emojiJson.filter((e) => e.category.toLowerCase().includes('flags')) }
];
function emojiStyles(emojiShortName: string | undefined, width: number) {
if (!emojiShortName) return;
const emojis = emojiJson;
const emoji = emojis.find((e) => e.short_names[0] === emojiShortName);
if (!emoji) return;
const sheet_x = (emoji.sheet_y * (width + 2));
const sheet_y = (emoji.sheet_x * (width + 2));
return {
background: 'url(/32.png)',
width: `${width}px`,
height: `${width}px`,
display: 'inline-block',
'background-position': `-${sheet_y + 1}px -${sheet_x + 1}px`,
'background-size': '6480% 6480%'
};
}
function scrollTo(categoryName: string) {
const category = document.getElementById(categoryName);
if (!emojiPane.value || !category) return;
// eww a magic number, it's just the emoji category picker height + category name height
emojiPane.value.scrollTop = category.offsetTop - 93;
}
</script>
<template>
<div class="p-3">
<div class="py-1.5 flex flex-col">
<div class="flex-row gap-x-2 overflow-x-scroll">
<div class="flex-row gap-x-2 overflow-x-scroll text-[#fefefe]">
<button
class="p-1.5 bg-inherit hover:backdrop-brightness-125 rounded-md transition-all"
@click="scrollTo('people')"

84
components/FriendChip.vue Normal file
View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import { PropType } from 'vue';
import { useUserStore } from '~/stores/userStore';
import { IUser } from '~/types';
const props = defineProps({
user: {
type: Object as PropType<IUser>,
required: true
},
request: {
type: Object as PropType<{ isRequest: boolean; incoming?: boolean; outgoing?: boolean; id?: string; }>,
required: true
}
});
async function cancelFriendRequest() {
if (!props.request.id) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
await $fetch(`/api/user/friends/${props.request.id}/cancel`, { method: 'POST', headers });
useUserStore().removeFriendRequest(props.request.id);
}
async function acceptFriendRequest() {
if (!props.request.id) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
await $fetch(`/api/user/friends/${props.request.id}/accept`, { method: 'POST', headers });
}
</script>
<template>
<div class="w-full px-3.5 py-2 flex flex-row border-t border-[var(--tertiary-bg)]">
<div class="flex flex-row">
<div class="bg-[var(--tertiary-bg)] w-10 h-10 rounded-full mr-2.5" />
<div>
{{ user.username }}
</div>
</div>
<div class="ml-auto flex gap-x-2 items-center">
<button
v-if="request.isRequest && request.incoming"
class="w-8 h-8 bg-[var(--tertiary-bg)] rounded-full hover:bg-[var(--tertiary-lightened-bg)] transition-colors flex items-center justify-center"
@click="acceptFriendRequest"
>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
><path
fill="none"
stroke="var(--primary-placeholder)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/></svg>
</span>
</button>
<button
v-if="request.isRequest"
class="w-8 h-8 bg-[var(--tertiary-bg)] rounded-full hover:bg-[var(--tertiary-lightened-bg)] transition-colors flex items-center justify-center"
@click="cancelFriendRequest"
>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
><path
fill="none"
stroke="var(--primary-placeholder)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/></svg>
</span>
</button>
</div>
</div>
</template>

View File

@@ -1,3 +1,35 @@
<script lang="ts" setup>
import { PropType, computed } from 'vue';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IInviteCode, IServer, SafeUser } from '~/types';
const props = defineProps({
invite: {
type: Object as PropType<IInviteCode>,
required: true
}
});
const user = storeToRefs(useUserStore()).user;
const userInServer = computed(() => {
return !!props.invite.server.participants.find((e: SafeUser) => e.id === user.value?.id);
});
async function joinServer(invite: IInviteCode) {
if (userInServer.value || !user.value) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const { server } = await $fetch('/api/guilds/joinGuild', { method: 'POST', body: { inviteId: invite.id }, headers }) as { server: IServer };
if (!server) return;
useServerStore().addServer(server);
// eslint-disable-next-line vue/no-mutating-props
props.invite.server.participants.push(user.value);
}
</script>
<template>
<div class="w-6/12 bg-[var(--secondary-bg)] mb-1 mt-0.5 p-4 rounded-md shadow-md mr-2">
<p class="text-sm font-semibold text-zinc-100">
@@ -34,43 +66,4 @@
</button>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IInviteCode, IServer, IUser, SafeUser } from '~/types';
export default {
props: {
invite: {
type: Object as PropType<IInviteCode>,
required: true
}
},
data() {
return {
user: storeToRefs(useUserStore()).user,
servers: storeToRefs(useServerStore()).servers,
};
},
computed: {
userInServer(): boolean {
return !!this.invite.server.participants.find((e: SafeUser) => e.id === this.user?.id);
}
},
methods: {
async joinServer(invite: IInviteCode) {
if (this.userInServer) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const { server } = await $fetch('/api/guilds/joinGuild', { method: 'POST', body: { inviteId: invite.id }, headers }) as { server: IServer };
if (!server) return;
useServerStore().addServer(server);
this.invite.server.participants.push(this.user);
},
}
};
</script>
</template>

View File

@@ -1,191 +1,3 @@
<template>
<div
class="relative message-wrapper"
@mouseleave="overflowShown = false"
>
<div
class="absolute right-0 mr-10 -top-[20px] h-fit opacity-0 pointer-events-none action-buttons z-[5]"
:class="(emojiPickerOpen) ? 'opacity-100 pointer-events-auto' : ''"
>
<div
:id="`actions-${message.id}`"
class="relative bg-[var(--tertiary-bg)] rounded-md border border-[rgb(32,34,37)] text-[var(--primary-text)] flex overflow-hidden"
>
<button
class="p-1 hover:backdrop-brightness-125 transition-all flex w-fit h-fit"
@click="openEmojiPicker()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m13 19l-1 1l-7.5-7.428A5 5 0 1 1 12 6.006a5 5 0 0 1 8.003 5.996M14 16h6m-3-3v6"
/>
</svg>
</button>
<button
v-if="!shiftPressed && !overflowShown"
class="p-1 hover:backdrop-brightness-125 transition-all flex w-fit h-fit"
@click="overflowShown = true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<circle
cx="5"
cy="12"
r="1"
/>
<circle
cx="12"
cy="12"
r="1"
/>
<circle
cx="19"
cy="12"
r="1"
/>
</g>
</svg>
</button>
<div
v-if="shiftPressed || overflowShown"
class="flex"
>
<button
class="p-1 hover:backdrop-brightness-125 transition-all flex w-[28px] h-[28px] items-center justify-center"
@click="copy(message.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="bg-[var(--primary-text)] rounded"
width="18"
height="18"
viewBox="0 0 24 24"
>
<path
fill="var(--tertiary-bg)"
d="M10 7v2H9v6h1v2H6v-2h1V9H6V7h4m6 0a2 2 0 0 1 2 2v6c0 1.11-.89 2-2 2h-4V7m4 2h-2v6h2V9Z"
/>
</svg>
</button>
<button
v-if="message.creator.id === user?.id"
class="p-1 hover:backdrop-brightness-125 transition-all flex w-[28px] h-[28px] items-center justify-center"
@click="deleteMessage()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="var(--primary-danger)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="transition-[backdrop-filter] hover:backdrop-brightness-125 ease-[cubic-bezier(.37,.64,.59,.33)] duration-150 my-4 px-7 py-2 message-wrapper items-center z-[1]"
:class="classes"
>
<div class="message-content">
<div class="message-sender-text">
<p
v-if="showUsername"
class="flex flex-row"
>
<span
ref="username"
class="mb-1 font-semibold w-fit cursor-pointer hover:underline"
@click="openUserProfile()"
>
{{ message.creator.username }}
</span>
<span
v-if="userIsOwner"
class="ml-0.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><path
class="text-yellow-300"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m12 6l4 6l5-4l-2 10H5L3 8l5 4z"
/></svg>
</span>
</p>
<div
class="break-words max-w-full whitespace-pre-wrap"
v-html="parseMessageBody(message.body, participants)"
/>
</div>
<div
v-for="invite in message.invites"
:key="invite.id"
>
<InviteCard :invite="invite" />
</div>
<div class="flex gap-2 flex-wrap">
<button
v-for="reaction in reactions"
:key="reaction.emoji"
class="py-0.5 px-1.5 bg-[var(--secondary-bg)] border items-center flex rounded-lg border-[var(--tertiary-bg)] hover:border-[var(--reaction-hover-border)] hover:bg-[var(--reaction-hover)] transition-colors shadow-sm max-h-[30px]"
:class="(reaction.users.find((e) => e.id === user?.id)) ? '!border-[var(--reaction-active-border)] hover:!border-[var(--reaction-active-border)]' : ''"
@click="toggleReaction(reaction.emoji)"
>
<div class="flex items-center mr-0.5 w-6 drop-shadow">
<span :style="emojiStyles(reaction.emoji, 17)" />
</div>
<div class="relative overflow-hidden ml-1.5">
<div
:key="reaction.users.length"
class="min-w-[9px] h-6"
>
<span class="dropshadow-sm">{{ reaction.users.length }}</span>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import { IPopupData, IMessage, SafeUser, IReaction } from '~/types';
@@ -335,6 +147,194 @@ export default {
};
</script>
<template>
<div
class="relative message-wrapper"
@mouseleave="overflowShown = false"
>
<div
class="absolute right-0 mr-10 -top-[20px] h-fit opacity-0 pointer-events-none action-buttons z-[5]"
:class="(emojiPickerOpen) ? 'opacity-100 pointer-events-auto' : ''"
>
<div
:id="`actions-${message.id}`"
class="relative bg-[var(--tertiary-bg)] rounded-md border border-[rgb(32,34,37)] text-[var(--primary-text)] flex overflow-hidden"
>
<button
class="p-1 hover:backdrop-brightness-125 transition-all flex w-fit h-fit"
@click="openEmojiPicker()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m13 19l-1 1l-7.5-7.428A5 5 0 1 1 12 6.006a5 5 0 0 1 8.003 5.996M14 16h6m-3-3v6"
/>
</svg>
</button>
<button
v-if="!shiftPressed && !overflowShown"
class="p-1 hover:backdrop-brightness-125 transition-all flex w-fit h-fit"
@click="overflowShown = true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<circle
cx="5"
cy="12"
r="1"
/>
<circle
cx="12"
cy="12"
r="1"
/>
<circle
cx="19"
cy="12"
r="1"
/>
</g>
</svg>
</button>
<div
v-if="shiftPressed || overflowShown"
class="flex"
>
<button
class="p-1 hover:backdrop-brightness-125 transition-all flex w-[28px] h-[28px] items-center justify-center"
@click="copy(message.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="bg-[var(--primary-text)] rounded"
width="18"
height="18"
viewBox="0 0 24 24"
>
<path
fill="var(--tertiary-bg)"
d="M10 7v2H9v6h1v2H6v-2h1V9H6V7h4m6 0a2 2 0 0 1 2 2v6c0 1.11-.89 2-2 2h-4V7m4 2h-2v6h2V9Z"
/>
</svg>
</button>
<button
v-if="message.creator.id === user?.id"
class="p-1 hover:backdrop-brightness-125 transition-all flex w-[28px] h-[28px] items-center justify-center"
@click="deleteMessage()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="var(--primary-danger)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="transition-[backdrop-filter] hover:backdrop-brightness-125 ease-[cubic-bezier(.37,.64,.59,.33)] duration-150 my-4 px-7 py-2 message-wrapper items-center z-[1]"
:class="classes"
>
<div class="message-content">
<div class="message-sender-text">
<p
v-if="showUsername"
class="flex flex-row"
>
<span
ref="username"
class="mb-1 font-semibold w-fit cursor-pointer hover:underline text-[#ffffff]"
@click="openUserProfile()"
>
{{ message.creator.username }}
</span>
<span
v-if="userIsOwner"
class="ml-0.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><path
class="text-yellow-300"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m12 6l4 6l5-4l-2 10H5L3 8l5 4z"
/></svg>
</span>
</p>
<div
class="break-words max-w-full whitespace-pre-wrap text-[#fafafa]"
v-html="parseMessageBody(message.body, participants)"
/>
</div>
<div
v-for="invite in message.invites"
:key="invite.id"
>
<InviteCard :invite="invite" />
</div>
<div class="flex gap-2 flex-wrap">
<button
v-for="reaction in reactions"
:key="reaction.emoji"
class="py-0.5 px-1.5 mt-1.5 bg-[var(--secondary-bg)] border items-center flex rounded-lg border-[var(--tertiary-bg)] hover:border-[var(--reaction-hover-border)] hover:bg-[var(--reaction-hover)] transition-colors shadow-sm max-h-[30px]"
:class="(reaction.users.find((e) => e.id === user?.id)) ? '!border-[var(--reaction-active-border)] hover:!border-[var(--reaction-active-border)]' : ''"
@click="toggleReaction(reaction.emoji)"
>
<div class="flex items-center mr-0.5 w-6 drop-shadow">
<span :style="emojiStyles(reaction.emoji, 17)" />
</div>
<div class="relative overflow-hidden ml-1.5">
<div
:key="reaction.users.length"
class="min-w-[9px] h-6"
>
<span class="dropshadow-sm text-[#efefef]">{{ reaction.users.length }}</span>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<style>
.message-wrapper:hover>div.action-buttons {
opacity: 100;

View File

@@ -1,178 +1,3 @@
<template>
<div
id="messagePane"
class="h-full relative bg-[var(--primary-bg)] flex flex-col"
@mouseenter="mouseEnter"
@mouseleave="mouseLeave"
>
<div class="px-4 py-3">
<div
v-if="!channel.DM"
class="flex items-center"
>
<span class="mr-1">
<svg
class="text-zinc-300/80 my-auto"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m5.41 21l.71-4h-4l.35-2h4l1.06-6h-4l.35-2h4l.71-4h2l-.71 4h6l.71-4h2l-.71 4h4l-.35 2h-4l-1.06 6h4l-.35 2h-4l-.71 4h-2l.71-4h-6l-.71 4h-2M9.53 9l-1.06 6h6l1.06-6h-6Z"
/>
</svg>
</span>
<span class="font-semibold">{{ channel.name }}</span>
</div>
<div
v-else
class="flex items-center"
>
<span class="mr-1">
<svg
class="text-zinc-300/80 my-auto"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<circle
cx="12"
cy="12"
r="4"
/>
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-5.5 8.28" />
</g>
</svg>
</span>
<span class="font-semibold">{{
participants.find((e: SafeUser) => e.id !== user?.id)?.username
}}</span>
</div>
</div>
<section
class="h-full overflow-hidden rounded-lg relative flex flex-col"
>
<div
ref="conversationPane"
class="h-[calc(100%-70px)] overflow-y-scroll"
>
<div class="w-full pb-1 bg-inherit">
<div>
<div v-if="channel.messages.length === 0">
<p>No messages yet</p>
</div>
<Message
v-for="(message, i) in channel.messages"
v-else
:key="message.id"
:message="message"
:shift-pressed="shiftPressed"
:show-username="calculateMessageDesign(message, i).showUsername"
:classes="calculateMessageDesign(message, i).classes"
:channel-id="channel.id"
:participants="participants"
/>
</div>
</div>
<div
v-if="search.show"
class="absolute bottom-[calc(75px+0.5rem)] mx-4 w-[calc(100vw-88px-240px-32px)] py-3 px-4 bg-[var(--secondary-bg)] rounded-lg shadow-md z-5"
>
<div class="relative flex flex-col">
<div
v-for="resultingUser in search.results"
:key="resultingUser.id"
class="mx-2 my-1 w-[calc(100vw-88px-240px-64px-16px)] px-4 py-3 hover:backdrop-brightness-125 select-none rounded-md transition-all"
@click="completeMention(resultingUser)"
>
{{ resultingUser.username }}
</div>
</div>
</div>
</div>
<div class="flex absolute flex-row bottom-0 w-full h-fit bg-inherit">
<form
class="relative px-4 w-full pt-1.5 h-fit pb-1"
@keyup="checkForMentions"
@keypress="typing($event)"
@submit.prevent="sendMessage"
@keydown.enter.exact.prevent="sendMessage"
>
<div
id="textbox"
class="px-4 rounded-md w-full min-h-[44px] h-fit bg-[var(--secondary-bg)] placeholder:text-[var(--primary-placeholder)] flex flex-row"
>
<textarea
ref="messageBox"
v-model="messageContent"
maxlength="5000"
type="text"
class="bg-transparent focus:outline-none py-2 w-full resize-none leading-relaxed h-[44px]"
cols="1"
placeholder="Send a Message..."
/>
<input
id="submit"
type="submit"
class="absolute -top-full -left-full invisible"
>
<label
for="submit"
class="py-1 px-1.5 h-fit my-auto cursor-pointer"
role="button"
><svg
width="32"
height="26"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14L21 3m0 0l-6.5 18a.55.55 0 0 1-1 0L10 14l-7-3.5a.55.55 0 0 1 0-1L21 3"
/>
</svg></label>
</div>
<div class="w-full h-4">
<p
v-if="usersTyping.length > 0"
class="text-sm"
>
<span v-if="usersTyping.length < 4">
<span
v-for="(username, i) in usersTyping"
:key="username"
class="font-semibold"
>
<span v-if="i === usersTyping.length - 1 && usersTyping.length > 1">and </span>
{{ username }}
<span v-if="i !== usersTyping.length - 1 && usersTyping.length > 1">, </span>
</span>
is typing
</span>
<span v-else>Several users are typing</span>
</p>
</div>
</form>
</div>
</section>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import { useActiveStore } from '~/stores/activeStore';
@@ -275,7 +100,10 @@ export default {
}
},
calculateMessageDesign(message: IMessage, i: number) {
if (i === 0 || (this.channel.messages[i - 1]?.creator.id !== message.creator.id || new Date(this.channel.messages[i-1]?.createdAt).getTime()+((30*60)*1000)<new Date(this.channel.messages[i]?.createdAt).getTime())) {
if (i === 0 || (
this.channel.messages[i - 1]?.creator.id !== message.creator.id ||
new Date(this.channel.messages[i-1]?.createdAt).getTime()+((30*60)*1000)<new Date(this.channel.messages[i]?.createdAt).getTime()
)) {
if (i !== this.channel.messages.length - 1) {
return { classes: 'mb-0 pb-0.5', showUsername: true };
}
@@ -392,5 +220,178 @@ export default {
},
},
};
</script>
</script>
<template>
<div
id="messagePane"
class="h-full relative bg-[var(--primary-bg)] flex flex-col"
@mouseenter="mouseEnter"
@mouseleave="mouseLeave"
>
<div class="px-4 py-3">
<div
v-if="!channel.DM"
class="flex items-center"
>
<span class="mr-1">
<svg
class="text-zinc-300/80 my-auto"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m5.41 21l.71-4h-4l.35-2h4l1.06-6h-4l.35-2h4l.71-4h2l-.71 4h6l.71-4h2l-.71 4h4l-.35 2h-4l-1.06 6h4l-.35 2h-4l-.71 4h-2l.71-4h-6l-.71 4h-2M9.53 9l-1.06 6h6l1.06-6h-6Z"
/>
</svg>
</span>
<span class="font-semibold text-[#fefefe]">{{ channel.name }}</span>
</div>
<div
v-else
class="flex items-center"
>
<span class="mr-1">
<svg
class="text-zinc-300/80 my-auto"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<circle
cx="12"
cy="12"
r="4"
/>
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-5.5 8.28" />
</g>
</svg>
</span>
<span class="font-semibold">{{
participants.find((e: SafeUser) => e.id !== user?.id)?.username
}}</span>
</div>
</div>
<section
class="h-full overflow-hidden rounded-lg relative flex flex-col"
>
<div
ref="conversationPane"
class="h-[calc(100%-70px)] overflow-y-scroll"
>
<div class="w-full pb-1 bg-inherit">
<div>
<div v-if="channel.messages.length === 0">
<p>No messages yet</p>
</div>
<Message
v-for="(message, i) in channel.messages"
v-else
:key="message.id"
:message="message"
:shift-pressed="shiftPressed"
:show-username="calculateMessageDesign(message, i).showUsername"
:classes="calculateMessageDesign(message, i).classes"
:channel-id="channel.id"
:participants="participants"
/>
</div>
</div>
<div
v-if="search.show"
class="absolute bottom-[calc(75px+0.5rem)] mx-4 w-[calc(100vw-88px-240px-32px)] py-3 px-4 bg-[var(--secondary-bg)] rounded-lg shadow-md z-5"
>
<div class="relative flex flex-col">
<div
v-for="resultingUser in search.results"
:key="resultingUser.id"
class="mx-2 my-1 w-[calc(100vw-88px-240px-64px-16px)] px-4 py-3 hover:backdrop-brightness-125 select-none rounded-md transition-all"
@click="completeMention(resultingUser)"
>
{{ resultingUser.username }}
</div>
</div>
</div>
</div>
<div class="flex absolute flex-row bottom-0 w-full h-fit bg-inherit">
<form
class="relative px-4 w-full pt-1.5 h-fit pb-1"
@keyup="checkForMentions"
@keypress="typing($event)"
@submit.prevent="sendMessage"
@keydown.enter.exact.prevent="sendMessage"
>
<div
id="textbox"
class="px-4 rounded-md w-full min-h-[44px] h-fit bg-[var(--secondary-bg)] placeholder:text-[var(--primary-placeholder)] flex flex-row text-[#fefefe]"
>
<textarea
ref="messageBox"
v-model="messageContent"
maxlength="5000"
type="text"
class="bg-transparent focus:outline-none py-2 w-full resize-none leading-relaxed h-[44px]"
cols="1"
placeholder="Send a Message..."
/>
<button
class="p-1 h-fit my-auto transition-colors duration-150 ease-in rounded-md"
:class="(messageContent.trim().length > 0) ? 'bg-blue-700 cursor-pointer' : 'text-[#78797A] cursor-default'"
@click="sendMessage"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036c-.01-.003-.019 0-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.016-.018Zm.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01l-.184-.092Z"
/>
<path
fill="currentColor"
d="M20.235 5.686c.432-1.195-.726-2.353-1.921-1.92L3.709 9.048c-1.199.434-1.344 2.07-.241 2.709l4.662 2.699l4.163-4.163a1 1 0 0 1 1.414 1.414L9.544 15.87l2.7 4.662c.638 1.103 2.274.957 2.708-.241l5.283-14.605Z"
/>
</g>
</svg>
</button>
</div>
<div class="w-full h-4">
<p
v-if="usersTyping.length > 0"
class="text-sm"
>
<span v-if="usersTyping.length < 4">
<span
v-for="(username, i) in usersTyping"
:key="username"
class="font-semibold"
>
<span v-if="i === usersTyping.length - 1 && usersTyping.length > 1">and </span>
{{ username }}
<span v-if="i !== usersTyping.length - 1 && usersTyping.length > 1">, </span>
</span>
is typing
</span>
<span v-else>Several users are typing</span>
</p>
</div>
</form>
</div>
</section>
</div>
</template>

View File

@@ -1,18 +1,3 @@
<template>
<Teleport to="body">
<div
v-if="opened"
class="absolute z-10 top-0 bottom-0 left-0 right-0"
>
<slot />
<div
class="bg-black/70 w-screen h-screen"
@click="$emit('close')"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
defineEmits(['close']);
@@ -22,4 +7,19 @@ defineProps({
required: true
}
});
</script>
</script>
<template>
<Teleport to="body">
<div
v-if="opened"
class="absolute z-10 top-0 bottom-0 left-0 right-0 text-[#fefefe]"
>
<slot />
<div
class="bg-black/70 w-screen h-screen"
@click="$emit('close')"
/>
</div>
</Teleport>
</template>

View File

@@ -1,3 +1,29 @@
<script lang="ts" setup>
import { useActiveStore } from '~/stores/activeStore';
import { useServerStore } from '~/stores/serverStore';
import { IServer } from '~/types';
import { ref } from 'vue';
const createServerModalOpen = ref(false);
const serverName = ref('');
const servers = storeToRefs(useServerStore()).servers;
const activeConversation = ref({
type: storeToRefs(useActiveStore()).type,
server: storeToRefs(useActiveStore()).server
});
async function createServer() {
const serverStore = useServerStore();
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const server: IServer = await $fetch('/api/channels/create', { method: 'post', body: { serverName: serverName.value }, headers });
createServerModalOpen.value = false;
serverName.value = '';
serverStore.addServer(server);
navigateTo(`/channel/${server.channels[0]?.id}`);
}
</script>
<template>
<nav class="bg-[var(--primary-bg)] h-screen p-4 grid grid-cols-1 grid-rows-[56px_1fr_56px] shadow shadow-black/80">
<div>
@@ -133,36 +159,4 @@
</div>
</Modal>
</nav>
</template>
<script lang="ts">
import { useActiveStore } from '~/stores/activeStore';
import { useServerStore } from '~/stores/serverStore';
import { IServer } from '~/types';
export default {
data() {
return {
createServerModalOpen: false,
serverName: '',
servers: storeToRefs(useServerStore()).servers,
activeConversation: {
type: storeToRefs(useActiveStore()).type,
server: storeToRefs(useActiveStore()).server
}
};
},
methods: {
async createServer() {
const serverStore = useServerStore();
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const server: IServer = await $fetch('/api/channels/create', { method: 'post', body: { serverName: this.serverName }, headers });
this.createServerModalOpen = false;
this.serverName = '';
serverStore.addServer(server);
navigateTo(`/channel/${server.channels[0].id}`);
}
}
};
</script>
</template>

View File

@@ -1,3 +1,17 @@
<script lang="ts" setup>
import { PropType } from 'vue';
defineProps({
openedBy: {
type: String as PropType<'emojiPicker' | 'userProfile'>,
required: true
},
opened: Boolean
});
defineEmits(['picked-emoji']);
</script>
<template>
<div
v-if="opened"
@@ -13,15 +27,4 @@
/>
</div>
</div>
</template>
<script>
export default {
props: {
openedBy: {
required: true
},
opened: Boolean
},
};
</script>
</template>

View File

@@ -1,3 +1,69 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useActiveStore, useDmStore, useServerStore, useUserStore } from '~/stores';
import { IChannel } from '~/types';
const activeServer = ref({
type: storeToRefs(useActiveStore()).type,
data: storeToRefs(useActiveStore()).server,
});
const user = storeToRefs(useUserStore()).user;
const dms = storeToRefs(useDmStore()).dms;
const channelName = ref('');
const createChannelModelOpen = ref(false);
const serverDropdownOpen = ref(false);
const userDropdownOpen = ref(false);
const userIsOwner = computed(() => {
return (
activeServer.value.type === 'server' &&
activeServer.value.data.server.participants.find((e) => e.id === user.value?.id)?.roles?.some((e) => e.owner === true)
);
});
const userIsAdmin = computed(() => {
return (
activeServer.value.type === 'server' &&
activeServer.value.data.server.participants.find((e) => e.id === user.value?.id)?.roles?.some((e) => e.administer === true)
);
});
const openCreateChannelModel = () => {
createChannelModelOpen.value = true;
};
const createChannel = async () => {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const channel = await $fetch(
`/api/guilds/${activeServer.value.data.server.id}/addChannel`,
{ method: 'POST', body: { channelName: channelName.value }, headers }
) as IChannel;
if (!channel) return;
useServerStore().addChannel(
activeServer.value.data.server.id,
channel
);
createChannelModelOpen.value = false;
navigateTo(`/channel/${channel.id}`);
};
const createInvite = async () => {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const inviteCode = await $fetch(
`/api/guilds/${activeServer.value.data.server.id}/createInvite`,
{ method: 'POST', headers }
);
};
const logout = () => {
useUserStore().logout();
};
</script>
<template>
<aside class="bg-[var(--secondary-bg)] min-w-60 w-60 h-screen shadow-sm text-white select-none relative z-[2]">
<div
@@ -15,18 +81,41 @@
<div
class="h-[calc(100%-12px)] grid grid-rows-[1fr_56px] bg-[var(--foreground-color)]"
>
<div class="h-fit">
<div class="flex gap-y-1.5 px-1.5 mt-2 flex-col overflow-y-auto overflow-x-hidden">
<nuxt-link to="/channel/@me">
<button
class="flex text-center bg-inherit px-2 py-1.5 w-full transition-all rounded-md drop-shadow-sm gap-1/5 cursor-pointer items-center hover:backdrop-brightness-[1.35]"
>
<span class="mr-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"
/></svg>
</span>
Friends
</button>
</nuxt-link>
<hr class="w-11/12 mx-auto border border-[var(--tertiary-lightened-bg)]" />
<nuxt-link
v-for="dm in dms"
:key="dm.id"
class="hover:no-underline"
:to="'/channel/@me/' + dm.id"
>
<div
class="mx-2 my-4 bg-inherit hover:backdrop-brightness-[1.35] px-2 py-2 max-h-10 h-10 overflow-ellipsis transition-all"
<button
class="flex text-center bg-inherit px-2 py-1.5 w-full transition-all rounded-md drop-shadow-sm gap-1/5 cursor-pointer items-center hover:backdrop-brightness-[1.35]"
>
{{ dm.dmParticipants?.find((e) => e.id !== user?.id)?.username }}
</div>
</button>
</nuxt-link>
</div>
</div>
@@ -138,7 +227,7 @@
>
<button
:class="(activeServer.data.channel.id === channel.id) ? 'backdrop-brightness-[1.35]' : 'hover:backdrop-brightness-[1.35]'"
class="flex text-center bg-inherit px-2 py-1.5 w-full transition-all rounded drop-shadow-sm gap-1/5 cursor-pointer items-center"
class="flex text-center bg-inherit px-2 py-1.5 w-full transition-all rounded-md drop-shadow-sm gap-1/5 cursor-pointer items-center"
>
<span class="h-fit">
<svg
@@ -159,7 +248,7 @@
</nuxt-link>
<button
v-if="userIsOwner || userIsAdmin"
class="flex text-center bg-inherit hover:backdrop-brightness-[1.45] px-2 py-1.5 w-full transition-all rounded drop-shadow-sm cursor-pointer items-center"
class="flex text-center bg-inherit hover:backdrop-brightness-[1.45] px-2 py-1.5 w-full transition-all rounded-md drop-shadow-sm cursor-pointer items-center"
@click="openCreateChannelModel"
>
<span>
@@ -224,7 +313,7 @@
</span>
</DropdownItem>
<DropdownItem
danger="true"
:danger="true"
@click="logout"
>
<span>
@@ -328,60 +417,4 @@
</div>
</Modal>
</aside>
</template>
<script lang="ts">
import { useActiveStore } from '~/stores/activeStore';
import { useDmStore } from '~/stores/dmStore';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel, IRole } from '~/types';
export default {
data() {
return {
activeServer: {
type: storeToRefs(useActiveStore()).type,
data: storeToRefs(useActiveStore()).server,
},
user: storeToRefs(useUserStore()).user,
dms: storeToRefs(useDmStore()).dms,
channelName: '',
createChannelModelOpen: false,
serverDropdownOpen: false,
userDropdownOpen: false,
};
},
computed: {
userIsOwner() {
return this.activeServer.type === 'server' && this.activeServer.data.server.participants.find((e) => e.id === this.user?.id)?.roles?.some((e) => e.owner === true);
},
userIsAdmin() {
return this.activeServer.type === 'server' && this.activeServer.data.server.participants.find((e) => e.id === this.user?.id)?.roles?.some((e) => e.administer === true);
}
},
methods: {
openCreateChannelModel() {
this.createChannelModelOpen = true;
},
async createChannel() {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const channel = await $fetch(`/api/guilds/${this.activeServer.data.server.id}/addChannel`, { method: 'POST', body: { channelName: this.channelName }, headers }) as IChannel;
if (!channel) return;
useServerStore().addChannel(this.activeServer.data.server.id, channel);
this.createChannelModelOpen = false;
navigateTo(`/channel/${channel.id}`);
},
async createInvite() {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const inviteCode = await $fetch(`/api/guilds/${this.activeServer.data.server.id}/createInvite`, { method: 'POST', headers });
},
logout() {
useUserStore().logout();
}
}
};
</script>
</template>

View File

@@ -1,5 +1,64 @@
<script lang="ts" setup>
import { useActiveStore } from '~/stores/activeStore';
import { useDmStore } from '~/stores/dmStore';
import { useEmojiPickerStore } from '~/stores/emojiPickerStore';
import { useUserStore } from '~/stores/userStore';
import { IUser, IRole } from '~/types';
import { ref, computed } from 'vue';
const userData = useUserStore().user;
const message = ref('');
async function fetchUser() {
const emojiPickerData = useEmojiPickerStore().emojiPickerData;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const activeServer = useActiveStore().server;
const isDm = useRoute().path.includes('@me');
let user: IUser | null;
if (isDm) {
user = await $fetch(`/api/user/${emojiPickerData.userId}/profile`, { headers }) as IUser | null;
} else {
user = await $fetch(`/api/user/${emojiPickerData.userId}/${activeServer.server.id}/profile`, { headers }) as IUser | null;
}
return { user, isDm };
}
const { user, isDm } = await fetchUser();
const roles = computed(() => {
return user?.roles?.filter((e: IRole) => e.owner === false) || [];
});
const userIsOwner = computed(() => {
return user?.roles?.some((e: IRole) => e.owner === true) || false;
});
async function sendDM() {
if (!message.value.trim()) return;
if (!user) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const preExistingDM = useDmStore().getByPartnerId(user.id);
if (!preExistingDM) return;
if (preExistingDM && useRoute().path !== `/channel/@me/${preExistingDM.id}`) {
await navigateTo(`/channel/@me/${preExistingDM.id}`);
}
await $fetch(`/api/channels/${preExistingDM.id}/sendMessage`, { method: 'post', body: { body: message.value }, headers });
message.value = '';
useEmojiPickerStore().closeEmojiPicker();
}
if (!user || !userData) throw new Error('unknown error');
</script>
<template>
<div class="w-[374px] max-h-[475px] overflow-y-scroll">
<div class="w-[374px] max-h-[475px] overflow-y-scroll text-[#fefefe]">
<div class="relative h-[calc(160px+56px)]">
<div class="w-full h-40 absolute">
<img
@@ -68,71 +127,4 @@
</div>
</div>
</div>
</template>
<script lang="ts">
import { useActiveStore } from '~/stores/activeStore';
import { useDmStore } from '~/stores/dmStore';
import { useEmojiPickerStore } from '~/stores/emojiPickerStore';
import { useUserStore } from '~/stores/userStore';
import { IUser, IRole } from '~/types';
export default {
async setup() {
const userData = useUserStore().user;
async function fetchUser() {
const emojiPickerData = useEmojiPickerStore().emojiPickerData;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const activeServer = useActiveStore().server;
const isDm = useRoute().path.includes('@me');
let user: IUser | null;
if (isDm) {
user = await $fetch(`/api/user/${emojiPickerData.userId}/profile`, { headers }) as IUser | null;
} else {
user = await $fetch(`/api/user/${emojiPickerData.userId}/${activeServer.server.id}/profile`, { headers }) as IUser | null;
}
return { user, isDm };
}
const { user, isDm } = await fetchUser();
if (!user) return;
return { user, isDm, fetchUser, userData };
},
data() {
return {
message: ''
};
},
computed: {
roles(): IRole[] {
return this.user.roles?.filter((e: IRole) => e.owner === false) || [];
},
userIsOwner(): boolean {
return this.user.roles?.some((e: IRole) => e.owner === true) || false;
}
},
methods: {
async sendDM() {
if (!this.message.trim()) return;
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const preExistingDM = useDmStore().getByPartnerId(this.user.id);
if (preExistingDM && useRoute().path !== `/channel/@me/${preExistingDM.id}`) {
await navigateTo(`/channel/@me/${preExistingDM.id}`);
}
await $fetch(`/api/channels/${preExistingDM.id}/sendMessage`, { method: 'post', body: { body: this.message }, headers });
this.message = '';
useEmojiPickerStore().closeEmojiPicker();
},
}
};
</script>
</template>