get the whole friends system to work now

This commit is contained in:
Soph :3 2026-01-04 18:20:04 +02:00
parent 342fd30d62
commit 126acf52f3
34 changed files with 1101 additions and 40 deletions

View file

@ -0,0 +1,7 @@
CREATE TABLE `friendRequest` (
`id` text PRIMARY KEY NOT NULL,
`from_user` text NOT NULL,
`to_user` text NOT NULL,
FOREIGN KEY (`from_user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`to_user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

View file

@ -0,0 +1,365 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e809c266-891b-4355-a87c-facd55a5293a",
"prevId": "69be2f8f-70ca-4016-b10f-60f64a99af73",
"tables": {
"channel": {
"name": "channel",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"server_id": {
"name": "server_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"messages": {
"name": "messages",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {
"channel_server_id_server_id_fk": {
"name": "channel_server_id_server_id_fk",
"tableFrom": "channel",
"tableTo": "server",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"friendRequest": {
"name": "friendRequest",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_user": {
"name": "from_user",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_user": {
"name": "to_user",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"friendRequest_from_user_user_id_fk": {
"name": "friendRequest_from_user_user_id_fk",
"tableFrom": "friendRequest",
"tableTo": "user",
"columnsFrom": [
"from_user"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"friendRequest_to_user_user_id_fk": {
"name": "friendRequest_to_user_user_id_fk",
"tableFrom": "friendRequest",
"tableTo": "user",
"columnsFrom": [
"to_user"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"members": {
"name": "members",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {
"group_owner_user_id_fk": {
"name": "group_owner_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"server": {
"name": "server",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"members": {
"name": "members",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"channels": {
"name": "channels",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {
"server_owner_user_id_fk": {
"name": "server_owner_user_id_fk",
"tableFrom": "server",
"tableTo": "user",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"friends": {
"name": "friends",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"servers": {
"name": "servers",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"groups": {
"name": "groups",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -43,6 +43,13 @@
"when": 1767531680914, "when": 1767531680914,
"tag": "0005_mute_mephisto", "tag": "0005_mute_mephisto",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1767537153441,
"tag": "0006_gifted_machine_man",
"breakpoints": true
} }
] ]
} }

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { type Data } from "$lib"; import { type Data, type OverviewUser } from "$lib";
import * as Collapsible from "$lib/components/ui/collapsible/index.js"; import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import MessagesSquare from "@lucide/svelte/icons/messages-square"; import MessagesSquare from "@lucide/svelte/icons/messages-square";
import MinusIcon from "@lucide/svelte/icons/minus"; import MinusIcon from "@lucide/svelte/icons/minus";
import PlusIcon from "@lucide/svelte/icons/plus"; import PlusIcon from "@lucide/svelte/icons/plus";
@ -12,8 +14,9 @@
import Input from "./ui/input/input.svelte"; import Input from "./ui/input/input.svelte";
import Button, { buttonVariants } from "./ui/button/button.svelte"; import Button, { buttonVariants } from "./ui/button/button.svelte";
import User from "./extra/User.svelte"; import User from "./extra/User.svelte";
import type { SessionValidationResult } from "$lib/server/auth";
let { currentPage = $bindable<string|null>(), data, ...restProps }: {currentPage: string|null, data: Data }= $props(); let { currentPage = $bindable<string|null>(), data, user, ...restProps }: {currentPage: string|null, data: Data, user: SessionValidationResult['user'] }= $props();
</script> </script>
<Sidebar.Root {...restProps}> <Sidebar.Root {...restProps}>
@ -34,22 +37,24 @@
<Dialog.Root> <Dialog.Root>
<Dialog.Trigger> <Dialog.Trigger>
<Button variant="outline" size="icon"> <Button variant={user!.friendRequests.length > 0 ? "destructive" : "outline"} size="icon">
<UserRoundPlus /> <UserRoundPlus />
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]"> <Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/addFriend">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Add a friend</Dialog.Title> <Dialog.Title>Add a friend</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Add a friend using their username. Add a friend using their username or manage pending requests.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Input name="username" placeholder="username" required /> <!-- input to add a new friend -->
<form method="POST" action="?/addFriend" class="mb-4">
<Input name="username" placeholder="username" required class="mb-2" />
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: "outline" })}> <Dialog.Close class={buttonVariants({ variant: "outline" })}>
Cancel Cancel
@ -57,7 +62,69 @@
<Button type="submit">Send request</Button> <Button type="submit">Send request</Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>
<!-- Tabs for Friend Requests -->
<Tabs.Root value="outgoing">
<Tabs.List>
<Tabs.Trigger value="outgoing">Outgoing</Tabs.Trigger>
<Tabs.Trigger value="incoming">Incoming</Tabs.Trigger>
</Tabs.List>
<!-- Outgoing Requests -->
<Tabs.Content value="outgoing">
{#if user.friendRequests.filter(r => r.fromUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No outgoing requests</p>
{:else}
{#each user.friendRequests.filter(r => r.fromUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Request sent</Card.Description>
</Card.Header>
<Card.Footer>
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">
Cancel
</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
<!-- Incoming Requests -->
<Tabs.Content value="incoming">
{#if user.friendRequests.filter(r => r.toUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No incoming requests</p>
{:else}
{#each user.friendRequests.filter(r => r.toUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Sent you a friend request</Card.Description>
</Card.Header>
<Card.Footer class="flex gap-2">
<!-- accept friend -->
<form method="POST" action="?/addFriend">
<input type="hidden" name="userId" value={request.fromUser} />
<Button type="submit" size="sm">Accept</Button>
</form>
<!-- decline friend -->
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">Decline</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<Dialog.Root> <Dialog.Root>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
/>
</AlertDialogPortal>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View file

@ -0,0 +1,37 @@
import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View file

@ -0,0 +1,16 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>

View file

@ -3,10 +3,12 @@ import { definePrefix, type Puuid } from "./puuid"
export const UserID = definePrefix("user"); export const UserID = definePrefix("user");
export const GroupID = definePrefix("group"); export const GroupID = definePrefix("group");
export const ServerID = definePrefix("srv"); export const ServerID = definePrefix("srv");
export const FriendRequestID = definePrefix("frq");
export type UserId = Puuid<"user">; export type UserId = Puuid<"user">;
export type GroupId = Puuid<"group">; export type GroupId = Puuid<"group">;
export type ServerId = Puuid<"srv">; export type ServerId = Puuid<"srv">;
export type FriendRequestID = Puuid<"frq">;
export const Status: Record<string, 1|2|3> = { export const Status: Record<string, 1|2|3> = {
OFFLINE: 1, OFFLINE: 1,

View file

@ -1,5 +1,5 @@
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray, or } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2'; import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
@ -94,8 +94,17 @@ export async function validateSessionToken(token: string) {
.where(inArray(table.group.id, (user.groups as string[]))) .where(inArray(table.group.id, (user.groups as string[])))
: []; : [];
const friendRequests = await db
.select({
id: table.friendRequest.id,
fromUser: table.friendRequest.fromUser,
toUser: table.friendRequest.toUser,
})
.from(table.friendRequest)
.where(or(eq(table.friendRequest.fromUser, user.id), eq(table.friendRequest.toUser, user.id)))
return { session, user: {...user, servers, friends, groups: groups.map(z => { return { ...z, members: (z.members as string[]).length}})} };
return { session, user: {...user, servers, friends, groups: groups.map(z => { return { ...z, members: (z.members as string[]).length}}), friendRequests} };
} }
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>; export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;

View file

@ -41,6 +41,17 @@ export const channel = sqliteTable("channel", {
messages: text('messages', { mode: "json"}).default([]).notNull(), messages: text('messages', { mode: "json"}).default([]).notNull(),
}) })
export const friendRequest = sqliteTable("friendRequest", {
id: text('id').primaryKey(),
fromUser: text('from_user').notNull().references(() => user.id),
toUser: text('to_user').notNull().references(() => user.id),
})
export const invite = sqliteTable("invite", {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => server.id),
code: text('code').notNull(),
})
export type Session = typeof session.$inferSelect; export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
export type Group = typeof group.$inferSelect; export type Group = typeof group.$inferSelect;

View file

@ -3,9 +3,10 @@ import { getRequestEvent } from '$app/server';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { ServerID } from '$lib'; import { FriendRequestID, ServerID } from '$lib';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm';
import { User } from '$lib/server/db/schema';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const user = requireLogin(); const user = requireLogin();
return { user }; return { user };
@ -16,19 +17,98 @@ export const actions = {
addFriend: async ({ request, locals }) => { addFriend: async ({ request, locals }) => {
const data = await request.formData(); const data = await request.formData();
const username = data.get('username'); const username = data.get('username');
const userId = data.get('userId');
if (username && !userId) {
if (typeof username !== 'string' || username.length < 3) { if (typeof username !== 'string' || username.length < 3) {
return fail(400, { error: 'Invalid username' }); return fail(400, { error: 'Invalid username' });
} }
}
/*await db.friendRequest.create({ let user: User[];
fromId: locals.user.id, if(username) {
toUsername: username user = await db.select().from(table.user).where(eq(table.user.username, username.toString())).limit(1);
});*/ } else if(userId) {
user = await db.select().from(table.user).where(eq(table.user.id, userId.toString())).limit(1);
} else {
return fail(400, { error: 'Missing username or userId' });
}
if(user?.length == 0)
return fail(400, { error: 'User not found' });
const friendRequest = await db.select()
.from(table.friendRequest)
.where(and(eq(table.friendRequest.fromUser, user[0].id), eq(table.friendRequest.toUser, locals.user!.id)))
.limit(1);
// user has already sent a request to us
// means we want to accept it
//
if(friendRequest?.length != 0) {
await db.delete(table.friendRequest)
.where(and(eq(table.friendRequest.fromUser, user[0].id), eq(table.friendRequest.toUser, locals.user!.id)))
.limit(1);
// add other guy to us
await db.transaction(async (tx) => {
await tx.update(table.user)
.set({ friends: locals.user?.friends.map(z => z.id).concat(user[0].id) })
.where(eq(table.user.id, locals.user!.id));
await tx.update(table.user)
.set({ friends: (user[0].friends as string[]).concat(locals.user!.id) })
.where(eq(table.user.id, user[0].id));
});
return {success: true}
}
// a request from us has already been sent to user
if(locals.user?.friendRequests.find(z => z.toUser == user[0].id && z.fromUser == locals.user!.id))
return fail(400, { error: 'Already sent request' });
await db.insert(table.friendRequest).values({
id: FriendRequestID.newV4(),
fromUser: locals.user!.id,
toUser: user[0].id,
});
return { success: true }; return { success: true };
}, },
cancelFriendRequest: async ({ request, locals }) => {
const data = await request.formData();
const requestId = data.get('requestId');
if (typeof requestId !== 'string') {
return fail(400, { error: 'Invalid request ID' });
}
// fetch the friend request
const friendRequest = await db.select()
.from(table.friendRequest)
.where(eq(table.friendRequest.id, requestId))
.limit(1);
if (!friendRequest?.length) {
return fail(404, { error: 'Friend request not found' });
}
const fr = friendRequest[0];
// only allow cancelling if it's related to current user
if (fr.fromUser !== locals.user!.id && fr.toUser !== locals.user!.id) {
return fail(403, { error: 'Not allowed' });
}
// delete the request
await db.delete(table.friendRequest)
.where(eq(table.friendRequest.id, requestId))
.limit(1);
return { success: true };
},
createGroup: async ({ request, locals }) => { createGroup: async ({ request, locals }) => {
const data = await request.formData(); const data = await request.formData();
const members = data.getAll('member'); const members = data.getAll('member');
@ -37,10 +117,7 @@ export const actions = {
return fail(400, { error: 'No members selected' }); return fail(400, { error: 'No members selected' });
} }
await db.group.create({ console.log(data, members, locals)
ownerId: locals.user.id,
members: members as string[]
});
return { success: true }; return { success: true };
}, },
@ -53,7 +130,29 @@ export const actions = {
return fail(400, { error: 'Invalid invite' }); return fail(400, { error: 'Invalid invite' });
} }
await db.server.joinByInvite(invite, locals.user.id); const inv = await db.select().from(table.invite).where(eq(table.invite.code, invite)).limit(1);
if(inv?.length == 0)
return fail(400, { error: 'Invalid invite' });
const server = await db.select().from(table.server).where(eq(table.server.id, inv[0].serverId)).limit(1);
if(server?.length == 0)
return fail(400, { error: 'Invalid server' });
if(locals.user!.servers.some(z => z.id == server[0].id))
return fail(400, { error: 'Already in server' });
await db.transaction(async (tx) => {
await tx.update(table.user)
.set({servers: locals.user!.servers.map(z => z.id).concat([server[0].id])})
.where(eq(table.user.id, locals.user!.id));
await tx.update(table.server)
.set({members: (server[0].members as string[]).concat([locals.user!.id])})
.where(eq(table.server.id, server[0].id));
})
return { success: true }; return { success: true };
}, },
@ -70,7 +169,7 @@ export const actions = {
.values({id: serverId, name, owner: locals.user!.id, members: [ locals.user!.id ]}); .values({id: serverId, name, owner: locals.user!.id, members: [ locals.user!.id ]});
await db.update(table.user) await db.update(table.user)
.set({servers: (locals.user!.servers as string[]).concat([serverId])}) .set({servers: locals.user!.servers.map(z => z.id).concat([serverId])})
.where(eq(table.user.id, locals.user!.id)); .where(eq(table.user.id, locals.user!.id));
redirect(303, `/app`); redirect(303, `/app`);

View file

@ -2,14 +2,16 @@
import { Status, type OverviewData, import { Status, type OverviewData,
GroupID, UserID, ServerID, GroupID, UserID, ServerID,
type GroupId, type ServerId, type UserId, type GroupId, type ServerId, type UserId,
type OverviewUser, type OverviewGroup, type OverviewServer } from "$lib"; type OverviewUser, type OverviewGroup, type OverviewServer,
type UserWithStatus} from "$lib";
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import AppSidebar from "$lib/components/app-sidebar.svelte"; import AppSidebar from "$lib/components/app-sidebar.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { ActionData } from './$types';
let { data }: { data: PageServerData } = $props(); let errorOpen = $state(true);
let { form, data }: { form: ActionData, data: PageServerData } = $props();
let currentPageID: (UserId|GroupId|ServerId)|null = $state(null); let currentPageID: (UserId|GroupId|ServerId)|null = $state(null);
let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state(); let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state();
@ -19,7 +21,7 @@
servers: [] servers: []
}); });
console.log(data, overview_data) console.log(form, data, overview_data)
$effect(() => { $effect(() => {
if (currentPageID) { if (currentPageID) {
@ -80,27 +82,44 @@
</script> </script>
{#if form}
<AlertDialog.Root bind:open={errorOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{form?.error ? "Ran into an error." : "Success!"}</AlertDialog.Title>
<AlertDialog.Description>
{form?.error || "Action completed succesfully."}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Close</AlertDialog.Cancel>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
<Sidebar.Provider> <Sidebar.Provider>
<AppSidebar bind:currentPage={currentPageID} data={overview_data} /> <AppSidebar bind:currentPage={currentPageID} user={data.user} data={overview_data} />
<Sidebar.Inset> <Sidebar.Inset>
<header class="flex h-16 shrink-0 items-center gap-2 border-b px-4"> <header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ms-1" /> <Sidebar.Trigger class="-ms-1" />
{#if currentPageID && currentPage} {#if currentPageID && currentPage}
{#if ServerID.is(currentPageID)} {#if ServerID.is(currentPageID)}
{@const server = (currentPage as Server)} {@const server = (currentPage as OverviewServer)}
<img src={server!.image} alt={server!.name} class="size-6 rounded-full" /> <img src={server!.image} alt={server!.name} class="size-6 rounded-full" />
<h1>{server!.name}</h1> <h1>{server!.name}</h1>
{:else if UserID.is(currentPageID)} {:else if UserID.is(currentPageID)}
{@const friend = (currentPage as User)} {@const friend = (currentPage as UserWithStatus)}
<img src={friend.image} alt={friend!.name} class="size-6 rounded-full" /> <img src={friend.image} alt={friend!.username} class="size-6 rounded-full" />
<h1>{friend!.name} [{friend.status == Status.ONLINE ? "Online!" : friend.status == Status.DND ? "DND" : friend.status == Status.OFFLINE ? "Offline" : "Unknown"}]</h1> <h1>{friend!.username} [{friend.status == Status.ONLINE ? "Online!" : friend.status == Status.DND ? "DND" : friend.status == Status.OFFLINE ? "Offline" : "Unknown"}]</h1>
{:else if GroupID.is(currentPageID)} {:else if GroupID.is(currentPageID)}
{@const group = (currentPage as Group)} {@const group = (currentPage as OverviewGroup)}
<h1>{group!.name} ({group.members} member{group.members > 1 ? "s" : ""})</h1> <h1>{group!.name} ({group.members} member{group.members > 1 ? "s" : ""})</h1>
{/if} {/if}