Sort members list #45

Merged
twig merged 11 commits from sort-members-list into main 2025-07-18 05:34:44 +00:00
20 changed files with 231 additions and 34 deletions

View file

@ -23,3 +23,17 @@ steps:
when:
- branch: main
event: push
- name: container-build-and-publish (staging)
image: docker
commands:
- docker login --username radical --password $PASSWORD git.gorb.app
- docker buildx build --platform linux/amd64,linux/arm64 --rm --push -t git.gorb.app/gorb/frontend:staging .
environment:
PASSWORD:
from_secret: docker_password
volumes:
- /var/run/podman/podman.sock:/var/run/docker.sock
when:
- branch: staging
event: push

View file

@ -26,9 +26,7 @@
<script lang="ts" setup>
const { fetchFriends } = useApi();
const friends = await fetchFriends().then((response) => {
return response.sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b)))
})
const friends = sortUsers(await fetchFriends())
const props = defineProps<{
variant: string

View file

@ -21,8 +21,6 @@
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const { fetchMembers } = useApi();
const props = defineProps<{
user: UserResponse
}>();

View file

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

View file

@ -1,24 +1,36 @@
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse, UserResponse } from "~/types/interfaces";
function ensureIsArray(list: any) {
if (Array.isArray(list)) {
return list
} else {
return []
}
}
export const useApi = () => {
async function fetchGuilds(): Promise<GuildResponse[] | undefined> {
return await fetchWithApi(`/guilds`);
async function fetchGuilds(): Promise<GuildResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds`));
}
async function fetchGuild(guildId: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/guilds/${guildId}`);
}
async function fetchChannels(guildId: string): Promise<ChannelResponse[] | undefined> {
return await fetchWithApi(`/guilds/${guildId}/channels`);
async function fetchMyGuilds(): Promise<GuildResponse[]> {
return ensureIsArray(await fetchWithApi(`/me/guilds`));
}
async function fetchChannels(guildId: string): Promise<ChannelResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/channels`));
}
async function fetchChannel(channelId: string): Promise<ChannelResponse | undefined> {
return await fetchWithApi(`/channels/${channelId}`)
}
async function fetchMembers(guildId: string): Promise<GuildMemberResponse[] | undefined> {
return await fetchWithApi(`/guilds/${guildId}/members`);
async function fetchMembers(guildId: string): Promise<GuildMemberResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/members`));
}
async function fetchMember(guildId: string, memberId: string): Promise<GuildMemberResponse | undefined> {
@ -34,12 +46,7 @@ export const useApi = () => {
}
async function fetchFriends(): Promise<UserResponse[]> {
const response = await fetchWithApi('/me/friends')
if (Array.isArray(response)) {
return response
} else {
return []
}
return ensureIsArray(await fetchWithApi('/me/friends'));
}
async function addFriend(username: string): Promise<void> {
@ -79,9 +86,18 @@ export const useApi = () => {
await fetchWithApi("/auth/verify-email", { method: "POST", body: { email } });
}
async function sendPasswordResetEmail(identifier: string): Promise<void> {
await fetchWithApi("/auth/reset-password", { method: "GET", query: { identifier } });
}
async function resetPassword(password: string, token: string) {
await fetchWithApi("/auth/reset-password", { method: "POST", body: { password, token } });
}
return {
fetchGuilds,
fetchGuild,
fetchMyGuilds,
fetchChannels,
fetchChannel,
fetchMembers,
@ -97,6 +113,8 @@ export const useApi = () => {
joinGuild,
createChannel,
fetchInstanceStats,
sendVerificationEmail
sendVerificationEmail,
sendPasswordResetEmail,
resetPassword
}
}

View file

@ -18,6 +18,7 @@
</div>
<div v-else id="auth-form-container">
<slot />
<div v-if="!['/recover', '/reset-password'].includes(route.path)">Forgot password? Recover <NuxtLink href="/recover">here</NuxtLink>!</div>
</div>
<div v-if="instanceUrl">
Instance URL is set to <span style="color: var(--primary-color);">{{ instanceUrl }}</span>
@ -36,7 +37,11 @@ const apiVersion = useRuntimeConfig().public.apiVersion;
const apiBase = useCookie("api_base");
const registrationEnabled = useState("registrationEnabled", () => true);
const auth = useAuth();
const route = useRoute();
const query = route.query as Record<string, string>;
const searchParams = new URLSearchParams(query);
searchParams.delete("token");
onMounted(async () => {
instanceUrl.value = useCookie("instance_url").value;
@ -111,6 +116,7 @@ const form = reactive({
#auth-form-container form {
display: flex;
flex-direction: column;
align-items: center;
text-align: left;
margin-top: 10dvh;
gap: 1em;

View file

@ -95,7 +95,7 @@ const options = [
if (invite.length == 6) {
try {
const joinedGuild = await api.joinGuild(invite);
guilds?.push(joinedGuild);
guilds.push(joinedGuild);
return await navigateTo(`/servers/${joinedGuild.uuid}`);
} catch (error) {
alert(`Couldn't use invite: ${error}`);
@ -151,7 +151,7 @@ const options = [
}
];
const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
const guilds = await api.fetchMyGuilds();
function createDropdown() {
const dropdown = h(GuildDropdown, { options });

View file

@ -16,7 +16,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
}
}
if (["/login", "/register"].includes(to.path) && !Object.keys(to.query).includes("special")) {
if (["/login", "/register", "/recover", "/reset-password"].includes(to.path) && !Object.keys(to.query).includes("special")) {
console.log("path is login or register");
const apiBase = useCookie("api_base");
console.log("apiBase gotten:", apiBase.value);

View file

@ -5,10 +5,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const guildId = to.params.serverId as string;
const channels: ChannelResponse[] | undefined = await fetchChannels(guildId);
const channels: ChannelResponse[] = await fetchChannels(guildId);
console.log("channels:", channels);
if (channels && channels.length > 0) {
if (channels.length > 0) {
console.log("wah");
return await navigateTo(`/servers/${guildId}/channels/${channels[0].uuid}`, { replace: true });
}

View file

@ -36,6 +36,7 @@ const form = reactive({
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
searchParams.delete("token");
const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base");
@ -48,7 +49,7 @@ if (apiBase.value) {
}
}
const registerUrl = `/register?${searchParams}`
const registerUrl = `/register?${searchParams}`;
const { login } = useAuth();

89
pages/recover.vue Normal file
View file

@ -0,0 +1,89 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form v-if="!emailFormSent" @submit.prevent="sendEmail">
<div>
<label for="identifier">Email or username</label>
<br>
<input type="text" name="identifier" id="identifier" v-model="emailForm.identifier">
</div>
<div>
<Button type="submit" text="Send email" variant="normal" />
</div>
</form>
<div v-else>
If an account with that username or email exists, an email will be sent to it shortly.
</div>
<div v-if="registrationEnabled">
Don't have an account? <NuxtLink :href="registerUrl">Register</NuxtLink> one!
</div>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const emailForm = reactive({
identifier: ""
});
const emailFormSent = ref(false);
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base");
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const token = ref(searchParams.get("token"))
searchParams.delete("token");
const { resetPassword } = useApi();
const registerUrl = `/register?${searchParams}`;
const loginUrl = `/login?${searchParams}`;
if (apiBase.value) {
console.log("apiBase:", apiBase.value);
const stats = await useApi().fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
}
}
const { sendPasswordResetEmail } = useApi();
async function sendEmail() {
try {
await sendPasswordResetEmail(emailForm.identifier);
emailFormSent.value = true;
} catch (error) {
errorValue.value = (error as any).toString();
}
}
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo(`/login?${searchParams}`);
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -86,6 +86,7 @@ const auth = useAuth();
const loggedIn = ref(await auth.getUser());
const query = new URLSearchParams(useRoute().query as Record<string, string>);
query.delete("token");
const user = await useAuth().getUser();

56
pages/reset-password.vue Normal file
View file

@ -0,0 +1,56 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form @submit.prevent="sendPassword">
<div>
<label for="password">Password</label>
<br>
<input type="password" name="password" id="password" v-model="passwordForm.password">
</div>
<div>
<Button type="submit" text="Submit" variant="normal" />
</div>
</form>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const loginUrl = `/login?${searchParams}`;
const token = ref(searchParams.get("token"))
if (!token.value) await navigateTo("/login");
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const { resetPassword } = useApi();
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo("/login?");
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -64,7 +64,7 @@ onActivated(async () => {
});
async function setArrayVariables() {
members.value = await fetchMembers(route.params.serverId as string);
members.value = sortMembers(await fetchMembers(route.params.serverId as string))
const guildUrl = `guilds/${route.params.serverId}`;
channels.value = await fetchWithApi(`${guildUrl}/channels`);
console.log("channels:", channels.value);

View file

@ -1,6 +1,6 @@
import type { GuildMemberResponse, UserResponse } from "~/types/interfaces";
export function getDisplayName(user: UserResponse, member?: GuildMemberResponse): string {
export default (user: UserResponse, member?: GuildMemberResponse): string => {
if (member?.nickname) return member.nickname
if (user.display_name) return user.display_name
return user.username

View file

@ -1,4 +1,4 @@
export async function hashPassword(password: string) {
export default async (password: string) => {
const encodedPass = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest("SHA-384", encodedPass);
const hashArray = Array.from(new Uint8Array(hashBuffer));

7
utils/sortMembers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { GuildMemberResponse } from "~/types/interfaces";
export default (members: GuildMemberResponse[]): GuildMemberResponse[] => {
twig marked this conversation as resolved Outdated

No need to name function here, name is derived from file name

No need to name function here, name is derived from file name
return members.sort((a, b) => {
return getDisplayName(a.user, a).localeCompare(getDisplayName(b.user, b))
})
}

7
utils/sortUsers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { UserResponse } from "~/types/interfaces";
export default (users: UserResponse[]): UserResponse[] => {
twig marked this conversation as resolved Outdated

No need to name function here, name is derived from file name

No need to name function here, name is derived from file name
return users.sort((a, b) => {
return getDisplayName(a).localeCompare(getDisplayName(b))
})
}

View file

@ -0,0 +1,3 @@
export default (username: string) => {
return /^[\w.-]+$/.test(username);
}

View file

@ -1,3 +0,0 @@
export function validateUsername(username: string) {
return /^[\w.-]+$/.test(username);
}