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,64 +1,71 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"useCookie": true,
"$fetch": true,
"definePageMeta": true,
"navigateTo": true,
"useRoute": true,
"useRouter": true,
"useRequestHeaders": true,
"parseMessageBody": true,
"storeToRefs": true,
"useNuxtApp": true,
"NodeJS": true,
"useHeadSafe": true,
"defineEmits": true
},
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"vue",
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
},
"overrides": [
{
"files": ["*.vue"],
"rules": {
"vue/multi-word-component-names": "off",
"vue/attribute-hyphenation": "off",
"vue/html-self-closing": "off"
}
}
]
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"useCookie": true,
"$fetch": true,
"definePageMeta": true,
"navigateTo": true,
"useRoute": true,
"useRouter": true,
"useRequestHeaders": true,
"parseMessageBody": true,
"storeToRefs": true,
"useNuxtApp": true,
"NodeJS": true,
"useHeadSafe": true,
"defineEmits": true,
"module": true
},
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"vue",
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"vue/component-tags-order": [
"error",
{
"order": [ "script", "template", "style" ]
}
]
},
"overrides": [
{
"files": ["*.vue"],
"rules": {
"vue/multi-word-component-names": "off",
"vue/attribute-hyphenation": "off",
"vue/html-self-closing": "off"
}
}
]
}

13
Dockerfile Executable file
View File

@@ -0,0 +1,13 @@
FROM node:16-alpine
WORKDIR /frontend
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "npm", "run", "dev" ]

View File

@@ -15,43 +15,9 @@
--reaction-hover: hsl(230, 31.2%, 12.5%);
--reaction-active-border: rgb(88,101,242);
--primary-danger: hsl(359, 66.7%, 54.1%);
/*
--background-color: hsl(230, 28%, 7.3%);
--foreground-color: hsl(230, 26%, 13%);
--primary-accent: hsl(180, 55%, 45%);
--message-input-color: hsl(228, 27.3%, 25%);
--primary-placeholder: hsl(218, 11%, 65%);
--primary-dark: hsl(225, 7.7%, 10.2%);
/* dropdown and emoji picker bg
--primary-700: hsl(230, 31.2%, 6.3%);
/* code block border
--primary-600: hsl(220, 6.8%, 17.3%);
/* modal bg
--primary-500: hsl(230, 28.7%, 9.8%);
/* reaction button bg, code block, and inline code bg
--primary-400: hsl(230, 12%, 19.2%);
/* action buttons
--primary-300: hsl(230, 26%, 15%);
/* nav button bg
--primary-200: hsl(230, 26%, 21.3%);
/* nav button hover bg
--primary-text: hsl(216, 3.7%, 73.5%);
/* main text color (duh)
--reaction-border: hsl(230, 33.4%, 18.7%);
/* reaction border on hover
--reaction-hover: hsl(230, 31.2%, 12.5%);
/* reaction bg on hover
--invite-members: var(--primary-accent);
/* color of dot next to server members count on invites
--primary-danger: hsl(359, 66.7%, 54.1%);
*/
}
* {
color: var(--primary-text);
scrollbar-width: none;
}

View File

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

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">
@@ -35,42 +67,3 @@
</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>

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>
<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']);
@@ -23,3 +8,18 @@ defineProps({
}
});
</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>
@@ -134,35 +160,3 @@
</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>

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"
@@ -14,14 +28,3 @@
</div>
</div>
</template>
<script>
export default {
props: {
openedBy: {
required: true
},
opened: Boolean
},
};
</script>

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>
@@ -329,59 +418,3 @@
</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>

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
@@ -69,70 +128,3 @@
</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>

26
docker-compose.yml Executable file
View File

@@ -0,0 +1,26 @@
version: '3'
services:
redis:
image: redis
ports:
- '6379:6379'
postgres:
image: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=discorddb
ports:
- '5432:5432'
nuxt:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/frontend
ports:
- '3000:3000'
- '24678:24678' # HMR port
depends_on:
- redis
- postgres

View File

@@ -15,10 +15,6 @@ export default defineNuxtConfig({
'@/assets/css/main.css'
],
devtools: {
enabled: false
},
modules: [
[
'@pinia/nuxt',
@@ -31,7 +27,6 @@ export default defineNuxtConfig({
],
},
],
'@nuxt/devtools'
],
postcss: {

11431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "nuxi dev",
"build": "nuxi build",
@@ -9,7 +10,7 @@
},
"dependencies": {
"@pinia/nuxt": "^0.4.6",
"@prisma/client": "^4.8.0",
"@prisma/client": "^4.13.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.3",
"emoji-regex": "^10.2.1",
@@ -23,7 +24,6 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@nuxt/devtools": "^0.4.1",
"@types/bcryptjs": "^2.4.2",
"@types/emoji-js": "^3.5.0",
"@types/node": "^18.15.11",
@@ -34,7 +34,7 @@
"eslint": "^8.38.0",
"eslint-plugin-vue": "^9.11.0",
"postcss": "^8.4.23",
"prisma": "^4.8.0",
"prisma": "^4.13.0",
"tailwindcss": "^3.3.1"
}
}

View File

@@ -1,4 +1,43 @@
<!-- eslint-disable vue/no-multiple-template-root -->
<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 { IChannel, SafeUser } from '~/types';
definePageMeta({
middleware: 'auth'
});
const route = useRoute();
const emojiPickerData = storeToRefs(useEmojiPickerStore()).emojiPickerData
if (useDmStore().dms.find((e) => { return e.id === route.params.dmId; } ) == undefined) navigateTo('/');
if (useActiveStore().server.channel.id !== route.params.dmId) {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const dm: IChannel = await $fetch(`/api/channels/${route.params.dmId}`, { headers });
if (!dm) throw new Error('couldnt find dm.');
useDmStore().addDM(dm);
if (typeof route.params.dmId !== 'string') throw new Error('route.params.dmId must be a string, but got an array presumably?');
useActiveStore().setActiveDM(dm);
useEmojiPickerStore().closeEmojiPicker();
}
const participants: SafeUser[] | undefined = useActiveStore().dm.dmParticipants;
if (!participants) throw new Error('no one is in this dm?');
const channel = useActiveStore().dm;
const friend = participants.find((e) => e.id !== useUserStore().user?.id)?.username;
useHeadSafe({
title: `@${friend} - Blop`
});
function pickedEmoji(emoji: string) {
const { $emit } = useNuxtApp();
$emit('pickedEmoji', emoji);
useEmojiPickerStore().closeEmojiPicker();
}
</script>
<template>
<MessagePane
:channel="channel"
@@ -17,63 +56,3 @@
</Transition>
</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 { IChannel, IMessage, SafeUser } from '~/types';
definePageMeta({
middleware: 'auth'
});
export default {
async setup() {
const route = useRoute();
if (useDmStore().dms.find((e) => { return e.id === route.params.dmId; } ) == undefined) navigateTo('/');
if (useActiveStore().server.channel.id !== route.params.dmId) {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const dm: IChannel = await $fetch(`/api/channels/${route.params.dmId}`, { headers });
if (!dm) throw new Error('couldnt find dm.');
useDmStore().addDM(dm);
if (typeof route.params.dmId !== 'string') throw new Error('route.params.dmId must be a string, but got an array presumably?');
useActiveStore().setActiveDM(dm);
useEmojiPickerStore().closeEmojiPicker();
}
const participants: SafeUser[] | undefined = useActiveStore().dm.dmParticipants;
if (!participants) throw new Error('no one is in this dm?');
const channel = useActiveStore().dm;
const friend = participants.find((e) => e.id !== useUserStore().user?.id)?.username;
useHeadSafe({
title: `@${friend} - Blop`
});
return {
channel,
participants
};
},
data() {
return {
emojiPickerData: storeToRefs(useEmojiPickerStore()).emojiPickerData,
};
},
methods: {
pickedEmoji(emoji: string) {
const { $emit } = useNuxtApp();
$emit('pickedEmoji', emoji);
useEmojiPickerStore().closeEmojiPicker();
},
}
};
</script>

View File

@@ -1,36 +1,120 @@
<template>
<form @submit.prevent="startDM">
<input v-model="userId">
<input type="submit">
</form>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useActiveStore } from '~/stores/activeStore';
import { useDmStore } from '~/stores/dmStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel } from '~/types';
import { ref, onMounted } from 'vue';
definePageMeta({
middleware: 'auth'
});
export default {
data() {
return {
userId: ''
};
},
mounted() {
useActiveStore().setActiveHome();
},
methods: {
async startDM() {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const server: IChannel = await $fetch('/api/channels/createDM', { method: 'post', body: { partnerId: this.userId }, headers });
const userId = ref('');
const user = useUserStore().user;
const selectedTab = ref('all' as 'all' | 'pending');
useDmStore().addDM(server);
useRouter().push({ path: '/channel/@me/' + server.id });
}
}
};
async function startDM() {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const server: IChannel = await $fetch('/api/channels/createDM', { method: 'post', body: { partnerId: userId.value }, headers });
useDmStore().addDM(server);
useRouter().push({ path: '/channel/@me/' + server.id });
}
function changeTab(type: 'all' | 'pending') {
selectedTab.value = type;
}
onMounted(() => {
useActiveStore().setActiveHome();
});
</script>
<template>
<div
class="h-full relative bg-[var(--primary-bg)] flex flex-col text-[#fefefe]"
>
<header class="py-3 px-2">
<div class="flex flex-row items-center">
<span class="flex items-center mr-4">
<span class="mr-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
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-4h.5m7.5 7l3.35-3.284a2.143 2.143 0 0 0 .005-3.071a2.242 2.242 0 0 0-3.129-.006l-.224.22l-.223-.22a2.242 2.242 0 0 0-3.128-.006a2.143 2.143 0 0 0-.006 3.071L18 22z"
/></svg>
</span>
<span class="text-xl">
Friends
</span>
</span>
<div class="flex flex-row gap-x-2">
<button
class="px-2.5 py-0.5 bg-inherit backdrop-filter rounded-md transition-all drop-shadow-sm"
:class="(selectedTab === 'all') ? 'backdrop-brightness-[1.35]' : 'hover:backdrop-brightness-[1.35]'"
@click="changeTab('all')"
>
All
</button>
<button
class="px-2.5 py-0.5 bg-inherit backdrop-filter rounded-md transition-all drop-shadow-sm"
:class="(selectedTab === 'pending') ? 'backdrop-brightness-[1.35]' : 'hover:backdrop-brightness-[1.35]'"
@click="changeTab('pending')"
>
Pending
<span
v-if="user.incomingFriendRequests?.length > 0"
class="text-sm px-1 py-px rounded bg-blue-700"
>{{ user?.incomingFriendRequests?.length }}</span>
</button>
</div>
</div>
</header>
<main class="px-3 py-1.5 w-full">
<div v-if="selectedTab === 'all'">
<div v-if="user.friends?.length > 0">
<div
v-for="friend in user?.friends"
:key="friend.id"
>
<FriendChip
:user="friend"
:request="{ isRequest: false }"
/>
</div>
</div>
<div v-else>
No friends yet...
</div>
</div>
<div v-if="selectedTab === 'pending'">
<div
v-for="outgoingRequest in user?.outgoingFriendRequests"
:key="outgoingRequest.id"
>
<FriendChip
:user="outgoingRequest.recipient"
:request="{ isRequest: true, outgoing: true, id: outgoingRequest.id }"
/>
</div>
<div
v-for="incomingRequest in user?.incomingFriendRequests"
:key="incomingRequest.id"
>
<FriendChip
:user="incomingRequest.sender"
:request="{ isRequest: true, incoming: true, id: incomingRequest.id }"
/>
</div>
</div>
</main>
</div>
</template>

View File

@@ -1,4 +1,57 @@
<!-- eslint-disable vue/no-multiple-template-root -->
<script lang="ts" setup>
import { useActiveStore } from '~/stores/activeStore';
import { useEmojiPickerStore } from '~/stores/emojiPickerStore';
import { useServerStore } from '~/stores/serverStore';
import { IChannel, IServer } from '~/types';
import { onMounted } from 'vue';
definePageMeta({
middleware: 'auth'
});
const route = useRoute();
const emojiPickerData = storeToRefs(useEmojiPickerStore()).emojiPickerData;
if (useServerStore().servers.find((e) => { return e.channels.some((e) => e.id === route.params.channelId); } ) == undefined) navigateTo('/');
if (useActiveStore().server.channel.id !== route.params.channelId) {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const [channel, server] = await Promise.all([
await $fetch(`/api/channels/${route.params.channelId}`, { headers }) as IChannel,
await $fetch(`/api/channels/${route.params.channelId}/guild`, { headers }) as IServer,
]);
if (!server) throw new Error('server not found, this means that the channel is serverless but not a dm????');
useServerStore().addServer(server);
if (typeof route.params.channelId !== 'string') throw new Error('route.params.id must be a string, but got an array presumably?');
useActiveStore().setActiveServer(channel, useServerStore().servers);
useEmojiPickerStore().closeEmojiPicker();
}
useEmojiPickerStore().closeEmojiPicker();
const server = useActiveStore().server;
useHeadSafe({
title: `#${server.channel.name} | ${server.server.name} - Blop`
});
function pickedEmoji(emoji: string) {
const { $emit } = useNuxtApp();
$emit('pickedEmoji', emoji);
useEmojiPickerStore().closeEmojiPicker();
}
onMounted(async () => {
const { $io } = useNuxtApp();
(await $io).on(`addChannel-${server.server.id}`, (ev) => {
const newChannel = ev as IChannel;
useServerStore().addChannel(server.server.id, newChannel);
});
});
</script>
<template>
<MessagePane
:channel="server.channel"
@@ -17,67 +70,3 @@
</Transition>
</div>
</template>
<script lang="ts">
import { useActiveStore } from '~/stores/activeStore';
import { useEmojiPickerStore } from '~/stores/emojiPickerStore';
import { useServerStore } from '~/stores/serverStore';
import { IChannel, IMessage, IServer } from '~/types';
definePageMeta({
middleware: 'auth'
});
export default {
async setup() {
const route = useRoute();
if (useServerStore().servers.find((e) => { return e.channels.some((e) => e.id === route.params.channelId); } ) == undefined) navigateTo('/');
if (useActiveStore().server.channel.id !== route.params.channelId) {
const headers = useRequestHeaders(['cookie']) as Record<string, string>;
const [channel, server] = await Promise.all([
await $fetch(`/api/channels/${route.params.channelId}`, { headers }) as IChannel,
await $fetch(`/api/channels/${route.params.channelId}/guild`, { headers }) as IServer,
]);
if (!server) throw new Error('server not found, this means that the channel is serverless but not a dm????');
useServerStore().addServer(server);
if (typeof route.params.channelId !== 'string') throw new Error('route.params.id must be a string, but got an array presumably?');
useActiveStore().setActiveServer(channel, useServerStore().servers);
useEmojiPickerStore().closeEmojiPicker();
}
useEmojiPickerStore().closeEmojiPicker();
const server = useActiveStore().server;
useHeadSafe({
title: `#${server.channel.name} | ${server.server.name} - Blop`
});
return {
server
};
},
data() {
return {
emojiPickerData: storeToRefs(useEmojiPickerStore()).emojiPickerData,
};
},
async mounted() {
const { $io } = useNuxtApp();
(await $io).on(`addChannel-${this.server.server.id}`, (ev) => {
const newChannel = ev as IChannel;
useServerStore().addChannel(this.server.server.id, newChannel);
});
},
methods: {
pickedEmoji(emoji: string) {
const { $emit } = useNuxtApp();
$emit('pickedEmoji', emoji);
useEmojiPickerStore().closeEmojiPicker();
},
}
};
</script>

View File

@@ -1,31 +1,18 @@
<template>
<div v-if="user.isLoggedIn">
Hello, {{ user.user?.username }}
<button @click="user.logout">
Logout
</button>
</div>
<div v-else>
<nuxt-link to="/login">
Login
</nuxt-link>
or
<nuxt-link to="/signup">
Sign Up
</nuxt-link>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useUserStore } from '~/stores/userStore';
definePageMeta({
middleware: 'auth'
});
export default {
setup() {
return { user: useUserStore() };
}
};
const user = useUserStore();
</script>
<template>
<div>
Hello, {{ user.user?.username }}
<button @click="user.logout">
Logout
</button>
</div>
</template>

View File

@@ -1,5 +1,40 @@
<script lang="ts" setup>
import { useDmStore } from '~/stores/dmStore';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel, IServer, SafeUser } from '~/types';
import { ref } from 'vue';
definePageMeta({
layout: 'clean'
});
const username = ref('');
const password = ref('');
async function login() {
if (!username.value || !password.value) return;
const loginData = await $fetch('/api/login', {
method: 'post', body: {
username: username.value,
password: password.value
},
}) as { token: string; user: SafeUser; };
const token = useCookie('sessionToken');
token.value = loginData.token;
useUserStore().setUser(loginData.user);
useServerStore().setServers(loginData.user.servers || [] as IServer[]);
useDmStore().setDms(loginData.user.channels || [] as IChannel[]);
return navigateTo('/');
}
</script>
<template>
<div class="w-screen h-screen flex justify-center items-center bg-[var(--primary-bg)]">
<div class="w-screen h-screen flex justify-center items-center bg-[var(--primary-bg)] text-[#fefefe]">
<div class="bg-[var(--secondary-bg)] rounded-xl shadow-2xl flex flex-row overflow-hidden">
<img
src="/nahil-naseer-xljtGZ2-P3Y-unsplash.jpg"
@@ -42,46 +77,3 @@
</div>
</div>
</template>
<script lang="ts">
import { useDmStore } from '~/stores/dmStore';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel, IServer, SafeUser } from '~/types';
definePageMeta({
layout: 'clean'
});
export default {
data() {
return {
username: '',
password: ''
};
},
methods: {
async login() {
if (!this.username || !this.password) return;
const loginData = await $fetch('/api/login', {
method: 'post', body: {
username: this.username,
password: this.password
},
}) as { token: string; user: SafeUser; };
const userId = useCookie('userId');
userId.value = loginData.user.id;
const token = useCookie('sessionToken');
token.value = loginData.token;
useUserStore().setUser(loginData.user);
useServerStore().setServers(loginData.user.servers || [] as IServer[]);
useDmStore().setDms(loginData.user.channels || [] as IChannel[]);
return navigateTo('/');
}
}
};
</script>

View File

@@ -1,5 +1,42 @@
<script lang="ts" setup>
import { useDmStore } from '~/stores/dmStore';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel, IServer, SafeUser } from '~/types';
import { ref } from 'vue';
definePageMeta({
layout: 'clean'
});
const username = ref('');
const password = ref('');
const email = ref('');
async function signup() {
if (!username.value || !password.value || !email.value) return;
const signupData = await $fetch('/api/signup', {
method: 'post', body: {
username: username.value,
email: email.value,
password: password.value
},
}) as { token: string; user: SafeUser; };
const token = useCookie('sessionToken');
token.value = signupData.token;
useUserStore().setUser(signupData.user);
useServerStore().setServers(signupData.user.servers || [] as IServer[]);
useDmStore().setDms(signupData.user.channels || [] as IChannel[]);
return navigateTo('/');
}
</script>
<template>
<div class="w-screen h-screen flex justify-center items-center bg-[var(--primary-bg)]">
<div class="w-screen h-screen flex justify-center items-center bg-[var(--primary-bg)] text-[#fefefe]">
<div class="bg-[var(--secondary-bg)] rounded-xl shadow-2xl flex flex-row overflow-hidden">
<img
src="/annie-spratt-8mqOw4DBBSg-unsplash.jpg"
@@ -49,48 +86,3 @@
</div>
</div>
</template>
<script lang="ts">
import { useDmStore } from '~/stores/dmStore';
import { useServerStore } from '~/stores/serverStore';
import { useUserStore } from '~/stores/userStore';
import { IChannel, IServer, SafeUser } from '~/types';
definePageMeta({
layout: 'clean'
});
export default {
data() {
return {
username: '',
email: '',
password: ''
};
},
methods: {
async signup() {
if (!this.username || !this.password || !this.email) return;
const signupData = await $fetch('/api/signup', {
method: 'post', body: {
username: this.username,
email: this.email,
password: this.password
},
}) as { token: string; user: SafeUser; };
const userId = useCookie('userId');
userId.value = signupData.user.id;
const token = useCookie('sessionToken');
token.value = signupData.token;
useUserStore().setUser(signupData.user);
useServerStore().setServers(signupData.user.servers || [] as IServer[]);
useDmStore().setDms(signupData.user.channels || [] as IChannel[]);
return navigateTo('/');
}
}
};
</script>

View File

@@ -1,16 +1,3 @@
<template>
<div class="bg-[var(--primary-bg)] h-full">
<Popup
:opened="true"
:openedBy="'userInfo'"
/>
<Popup
:opened="true"
:openedBy="'emojiPicker'"
/>
</div>
</template>
<script lang="ts">
import { useEmojiPickerStore } from '~/stores/emojiPickerStore';
@@ -29,3 +16,16 @@ export default {
}
};
</script>
<template>
<div class="bg-[var(--primary-bg)] h-full">
<Popup
:opened="true"
:openedBy="'userInfo'"
/>
<Popup
:opened="true"
:openedBy="'emojiPicker'"
/>
</div>
</template>

View File

@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {

View File

@@ -1,6 +1,5 @@
import { IServer } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,5 @@
import { IChannel, IServer, SafeUser } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
@@ -44,6 +43,9 @@ export default defineEventHandler(async (event) => {
}
},
messages: {
orderBy: {
createdAt: 'asc',
},
select: {
id: true,
body: true,

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,6 @@
import { Prisma } from '@prisma/client';
import emojiRegex from 'emoji-regex';
import { Prisma, PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
@@ -37,6 +37,15 @@ export default defineEventHandler(async (event) => {
}
},
reactions: {
where: {
users: {
some: {
NOT: {
id: undefined,
}
}
}
},
select: {
id: true,
emoji: true,

View File

@@ -1,7 +1,6 @@
import { IChannel, IServer, SafeUser, IMessage } from '~/types';
import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
declare global {
let io: Server;

View File

@@ -1,6 +1,5 @@
import { IServer } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,5 @@
import { IChannel, SafeUser } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
@@ -10,15 +9,17 @@ export default defineEventHandler(async (event) => {
});
}
const { partnerId } = await readBody(event);
const body = await readBody(event);
if (!partnerId) {
if (!body || !body.partnerId) {
throw createError({
statusCode: 400,
statusMessage: 'A friend is required to create a DM.',
});
}
const { partnerId } = body;
const partner = await prisma.user.findFirst({
where: {
id: partnerId

View File

@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client';
import { SafeUser } from '~/types';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
@@ -17,6 +16,34 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
username: true,
outgoingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
recipient: {
select: {
id: true,
username: true
}
}
}
},
incomingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
sender: {
select: {
id: true,
username: true
}
}
}
},
friends: {
select: {
id: true,

View File

@@ -1,6 +1,5 @@
import { IChannel, IServer, SafeUser } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,5 @@
import { IInviteCode, IServer, SafeUser } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,5 @@
import { IServer } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,6 +1,5 @@
import { IInviteCode, IServer } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -2,9 +2,8 @@ import bcryptjs from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import * as dotenv from 'dotenv';
import crypto from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { IUser } from '~/types';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
dotenv.config();
export default defineEventHandler(async (event) => {
@@ -19,13 +18,47 @@ export default defineEventHandler(async (event) => {
});
}
let user = await prisma.user.findFirst({
const user = await prisma.user.findFirst({
where: {
username: body.username
},
select: {
id: true,
username: true,
outgoingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
recipient: {
select: {
id: true,
username: true
}
}
}
},
incomingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
sender: {
select: {
id: true,
username: true
}
}
}
},
friends: {
select: {
id: true,
username: true,
}
},
passwordhash: true,
servers: {
select: {

View File

@@ -2,9 +2,8 @@ import bcryptjs from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import * as dotenv from 'dotenv';
import crypto from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { IUser, SafeUser } from '~/types';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
dotenv.config();
export default defineEventHandler(async (event) => {
@@ -50,6 +49,40 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
username: true,
outgoingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
recipient: {
select: {
id: true,
username: true
}
}
}
},
incomingFriendRequests: {
where: {
status: 'sent'
},
select: {
id: true,
sender: {
select: {
id: true,
username: true
}
}
}
},
friends: {
select: {
id: true,
username: true,
}
},
servers: {
select: {
id: true,

View File

@@ -1,6 +1,5 @@
import { IServer } from '~/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -0,0 +1,69 @@
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthenticated',
});
}
const requestId = event.context.params?.id;
if (!requestId) return;
const request = await prisma.friendRequest.findFirst({
where: {
id: requestId
}
});
if (!request?.id) {
throw createError({
statusCode: 404,
statusMessage: 'friend request not found.'
});
}
if (request?.recipientId !== event.context.user.id) {
throw createError({
statusCode: 403,
statusMessage: 'You do not have permission to accept this friend request.'
});
}
await prisma.user.update({
where: {
id: event.context.user.id
},
data: {
friends: {
connect: [{ id: request.senderId }]
}
}
});
await prisma.user.update({
where: {
id: request.senderId
},
data: {
friends: {
connect: [{ id: event.context.user.id }]
}
}
});
await prisma.friendRequest.update({
where: {
id: requestId
},
data: {
status: 'accepted'
}
});
return {
message: 'friend request accepted successfully.'
};
});

View File

@@ -0,0 +1,37 @@
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthenticated',
});
}
const requestId = event.context.params?.id;
if (!requestId) return;
const request = await prisma.friendRequest.findFirst({
where: {
id: requestId
}
});
if (request?.senderId !== event.context.user.id && request?.recipientId !== event.context.user.id) {
throw createError({
statusCode: 403,
statusMessage: 'You do not have permission to cancel this friend request.'
});
}
await prisma.friendRequest.delete({
where: {
id: requestId
}
});
return {
message: 'successfully cancelled this friend request.'
};
});

View File

@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client';
import { IChannel, IServer } from '~/types';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
if (!event.context.user.authenticated) {

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
const { sessionToken } = parseCookies(event);

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '~/server/utils/prisma';
export default defineEventHandler(async (event) => {
const cookies = parseCookies(event);

View File

@@ -1,5 +1,4 @@
import Redis from 'ioredis';
const redis = new Redis();
import redis from '~/server/utils/redis';
const INCREMENT_LIMIT = 5;
const LIMIT_TIME = 700; // milliseconds

View File

@@ -1,8 +1,6 @@
import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client';
import { IChannel, IServer, IUser, SafeUser } from '~~/types';
import { Socket } from 'socket.io';
const prisma = new PrismaClient();
import { Server, Socket } from 'socket.io';
import { IChannel, IServer, IUser, SafeUser } from '~/types';
import prisma from '~/server/utils/prisma';
export default defineEventHandler(({ node }) => {
if (global.io) return;

4
server/utils/prisma.ts Normal file
View File

@@ -0,0 +1,4 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

6
server/utils/redis.ts Normal file
View File

@@ -0,0 +1,6 @@
import Redis from 'ioredis';
const redis = new Redis({
host: 'redis'
});
export default redis;

7
stores/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useActiveStore } from "./activeStore";
import { useDmStore } from "./dmStore";
import { useUserStore } from "./userStore";
import { useServerStore } from "./serverStore";
import { useEmojiPickerStore } from "./emojiPickerStore";
export { useActiveStore, useDmStore, useEmojiPickerStore, useServerStore, useUserStore };

View File

@@ -12,14 +12,14 @@ export const useUserStore = defineStore('userStore', {
const that = this;
return new Promise<boolean>(resolve => {
function checkFlag() {
function checkAuthStatus() {
if(that.isLoggedIn === true) {
resolve(true);
}
setTimeout(checkFlag, 100);
setTimeout(checkAuthStatus, 100);
}
checkFlag();
checkAuthStatus();
});
},
},
@@ -28,13 +28,17 @@ export const useUserStore = defineStore('userStore', {
this.user = user;
this.isLoggedIn = true;
},
removeFriendRequest(friendRequestId: string) {
const type = (this.user?.incomingFriendRequests?.find((e) => e.id === friendRequestId)) ? 'incomingFriendRequests' : 'outgoingFriendRequests';
this.user[type] = this.user[type].filter((e) => e.id !== friendRequestId);
},
async logout() {
const { $io, $emit } = useNuxtApp();
(await $io).disconnect();
await $fetch('/api/user/logout');
useCookie('sessionToken').value = null;
useCookie('userId').value = null;
this.user = null;
this.isLoggedIn = false;

View File

@@ -2,6 +2,9 @@ export interface IUser {
id: string;
email: string;
username: string;
friends?: Array<IUser>;
outgoingFriendRequests?: Array<IFriendRequest>;
incomingFriendRequests?: Array<IFriendRequest>;
online?: boolean;
passwordhash: string;
servers?: Array<IServer>;
@@ -86,3 +89,9 @@ export interface IPopupData {
};
userId?: string;
}
export interface IFriendRequest {
id: string;
recipient?: IUser;
sender?: IUser;
}

View File

@@ -10,7 +10,7 @@ export default function parseBody(body: string, participants: SafeUser[]) {
[/&#126;&#126;\s?([^\n]+)&#126;&#126;/g, '<s>$1</s>'],
// code lines and blocks
[/&#96;&#96;&#96;(.+?)&#96;&#96;&#96;/g, '<pre class=\'codeblock\'><code>$1</code></pre>'],
[/&#96;&#96;&#96;(&#10;)?(.+?)&#96;&#96;&#96;/g, '<pre class=\'codeblock\'><code>$2</code></pre>'],
[/(?<!&#96;)&#96;(.+?)&#96;(?!&#96;)/g, '<code class=\'inline-code\'>$1</code>'],
];