feat: file uploads

This commit is contained in:
2025-03-16 06:50:11 -04:00
parent 50c8d18df9
commit f6d75964c1
25 changed files with 1393 additions and 320 deletions

View File

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TrevStack</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="tap" class="min-h-screen bg-base text-text">

View File

@ -10,12 +10,69 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file user/v1/user.proto.
*/
export const file_user_v1_user: GenFile = /*@__PURE__*/
fileDesc("ChJ1c2VyL3YxL3VzZXIucHJvdG8SB3VzZXIudjEiXQoVQ2hhbmdlUGFzc3dvcmRSZXF1ZXN0EhQKDG9sZF9wYXNzd29yZBgBIAEoCRIUCgxuZXdfcGFzc3dvcmQYAiABKAkSGAoQY29uZmlybV9wYXNzd29yZBgDIAEoCSIYChZDaGFuZ2VQYXNzd29yZFJlc3BvbnNlIjsKDUFQSUtleVJlcXVlc3QSEAoIcGFzc3dvcmQYASABKAkSGAoQY29uZmlybV9wYXNzd29yZBgCIAEoCSIdCg5BUElLZXlSZXNwb25zZRILCgNrZXkYASABKAkynwEKC1VzZXJTZXJ2aWNlElMKDkNoYW5nZVBhc3N3b3JkEh4udXNlci52MS5DaGFuZ2VQYXNzd29yZFJlcXVlc3QaHy51c2VyLnYxLkNoYW5nZVBhc3N3b3JkUmVzcG9uc2UiABI7CgZBUElLZXkSFi51c2VyLnYxLkFQSUtleVJlcXVlc3QaFy51c2VyLnYxLkFQSUtleVJlc3BvbnNlIgBCnQEKC2NvbS51c2VyLnYxQglVc2VyUHJvdG9QAVpGZ2l0aHViLmNvbS9zcG90ZGVtbzQvdHJldnN0YWNrL3NlcnZlci9pbnRlcm5hbC9zZXJ2aWNlcy91c2VyL3YxO3VzZXJ2MaICA1VYWKoCB1VzZXIuVjHKAgdVc2VyXFYx4gITVXNlclxWMVxHUEJNZXRhZGF0YeoCCFVzZXI6OlYxYgZwcm90bzM");
fileDesc("ChJ1c2VyL3YxL3VzZXIucHJvdG8SB3VzZXIudjEiVgoEVXNlchIKCgJpZBgBIAEoDRIQCgh1c2VybmFtZRgCIAEoCRIcCg9wcm9maWxlX3BpY3R1cmUYAyABKAlIAIgBAUISChBfcHJvZmlsZV9waWN0dXJlIhAKDkdldFVzZXJSZXF1ZXN0Ii4KD0dldFVzZXJSZXNwb25zZRIbCgR1c2VyGAEgASgLMg0udXNlci52MS5Vc2VyIl0KFVVwZGF0ZVBhc3N3b3JkUmVxdWVzdBIUCgxvbGRfcGFzc3dvcmQYASABKAkSFAoMbmV3X3Bhc3N3b3JkGAIgASgJEhgKEGNvbmZpcm1fcGFzc3dvcmQYAyABKAkiNQoWVXBkYXRlUGFzc3dvcmRSZXNwb25zZRIbCgR1c2VyGAEgASgLMg0udXNlci52MS5Vc2VyIj4KEEdldEFQSUtleVJlcXVlc3QSEAoIcGFzc3dvcmQYASABKAkSGAoQY29uZmlybV9wYXNzd29yZBgCIAEoCSIgChFHZXRBUElLZXlSZXNwb25zZRILCgNrZXkYASABKAkiPgobVXBkYXRlUHJvZmlsZVBpY3R1cmVSZXF1ZXN0EhEKCWZpbGVfbmFtZRgBIAEoCRIMCgRkYXRhGAIgASgMIjsKHFVwZGF0ZVByb2ZpbGVQaWN0dXJlUmVzcG9uc2USGwoEdXNlchgBIAEoCzINLnVzZXIudjEuVXNlcjLPAgoLVXNlclNlcnZpY2USPgoHR2V0VXNlchIXLnVzZXIudjEuR2V0VXNlclJlcXVlc3QaGC51c2VyLnYxLkdldFVzZXJSZXNwb25zZSIAElMKDlVwZGF0ZVBhc3N3b3JkEh4udXNlci52MS5VcGRhdGVQYXNzd29yZFJlcXVlc3QaHy51c2VyLnYxLlVwZGF0ZVBhc3N3b3JkUmVzcG9uc2UiABJECglHZXRBUElLZXkSGS51c2VyLnYxLkdldEFQSUtleVJlcXVlc3QaGi51c2VyLnYxLkdldEFQSUtleVJlc3BvbnNlIgASZQoUVXBkYXRlUHJvZmlsZVBpY3R1cmUSJC51c2VyLnYxLlVwZGF0ZVByb2ZpbGVQaWN0dXJlUmVxdWVzdBolLnVzZXIudjEuVXBkYXRlUHJvZmlsZVBpY3R1cmVSZXNwb25zZSIAQp0BCgtjb20udXNlci52MUIJVXNlclByb3RvUAFaRmdpdGh1Yi5jb20vc3BvdGRlbW80L3RyZXZzdGFjay9zZXJ2ZXIvaW50ZXJuYWwvc2VydmljZXMvdXNlci92MTt1c2VydjGiAgNVWFiqAgdVc2VyLlYxygIHVXNlclxWMeICE1VzZXJcVjFcR1BCTWV0YWRhdGHqAghVc2VyOjpWMWIGcHJvdG8z");
/**
* @generated from message user.v1.ChangePasswordRequest
* @generated from message user.v1.User
*/
export type ChangePasswordRequest = Message<"user.v1.ChangePasswordRequest"> & {
export type User = Message<"user.v1.User"> & {
/**
* @generated from field: uint32 id = 1;
*/
id: number;
/**
* @generated from field: string username = 2;
*/
username: string;
/**
* @generated from field: optional string profile_picture = 3;
*/
profilePicture?: string;
};
/**
* Describes the message user.v1.User.
* Use `create(UserSchema)` to create a new message.
*/
export const UserSchema: GenMessage<User> = /*@__PURE__*/
messageDesc(file_user_v1_user, 0);
/**
* @generated from message user.v1.GetUserRequest
*/
export type GetUserRequest = Message<"user.v1.GetUserRequest"> & {
};
/**
* Describes the message user.v1.GetUserRequest.
* Use `create(GetUserRequestSchema)` to create a new message.
*/
export const GetUserRequestSchema: GenMessage<GetUserRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 1);
/**
* @generated from message user.v1.GetUserResponse
*/
export type GetUserResponse = Message<"user.v1.GetUserResponse"> & {
/**
* @generated from field: user.v1.User user = 1;
*/
user?: User;
};
/**
* Describes the message user.v1.GetUserResponse.
* Use `create(GetUserResponseSchema)` to create a new message.
*/
export const GetUserResponseSchema: GenMessage<GetUserResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 2);
/**
* @generated from message user.v1.UpdatePasswordRequest
*/
export type UpdatePasswordRequest = Message<"user.v1.UpdatePasswordRequest"> & {
/**
* @generated from field: string old_password = 1;
*/
@ -33,29 +90,33 @@ export type ChangePasswordRequest = Message<"user.v1.ChangePasswordRequest"> & {
};
/**
* Describes the message user.v1.ChangePasswordRequest.
* Use `create(ChangePasswordRequestSchema)` to create a new message.
* Describes the message user.v1.UpdatePasswordRequest.
* Use `create(UpdatePasswordRequestSchema)` to create a new message.
*/
export const ChangePasswordRequestSchema: GenMessage<ChangePasswordRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 0);
export const UpdatePasswordRequestSchema: GenMessage<UpdatePasswordRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 3);
/**
* @generated from message user.v1.ChangePasswordResponse
* @generated from message user.v1.UpdatePasswordResponse
*/
export type ChangePasswordResponse = Message<"user.v1.ChangePasswordResponse"> & {
export type UpdatePasswordResponse = Message<"user.v1.UpdatePasswordResponse"> & {
/**
* @generated from field: user.v1.User user = 1;
*/
user?: User;
};
/**
* Describes the message user.v1.ChangePasswordResponse.
* Use `create(ChangePasswordResponseSchema)` to create a new message.
* Describes the message user.v1.UpdatePasswordResponse.
* Use `create(UpdatePasswordResponseSchema)` to create a new message.
*/
export const ChangePasswordResponseSchema: GenMessage<ChangePasswordResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 1);
export const UpdatePasswordResponseSchema: GenMessage<UpdatePasswordResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 4);
/**
* @generated from message user.v1.APIKeyRequest
* @generated from message user.v1.GetAPIKeyRequest
*/
export type APIKeyRequest = Message<"user.v1.APIKeyRequest"> & {
export type GetAPIKeyRequest = Message<"user.v1.GetAPIKeyRequest"> & {
/**
* @generated from field: string password = 1;
*/
@ -68,16 +129,16 @@ export type APIKeyRequest = Message<"user.v1.APIKeyRequest"> & {
};
/**
* Describes the message user.v1.APIKeyRequest.
* Use `create(APIKeyRequestSchema)` to create a new message.
* Describes the message user.v1.GetAPIKeyRequest.
* Use `create(GetAPIKeyRequestSchema)` to create a new message.
*/
export const APIKeyRequestSchema: GenMessage<APIKeyRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 2);
export const GetAPIKeyRequestSchema: GenMessage<GetAPIKeyRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 5);
/**
* @generated from message user.v1.APIKeyResponse
* @generated from message user.v1.GetAPIKeyResponse
*/
export type APIKeyResponse = Message<"user.v1.APIKeyResponse"> & {
export type GetAPIKeyResponse = Message<"user.v1.GetAPIKeyResponse"> & {
/**
* @generated from field: string key = 1;
*/
@ -85,31 +146,86 @@ export type APIKeyResponse = Message<"user.v1.APIKeyResponse"> & {
};
/**
* Describes the message user.v1.APIKeyResponse.
* Use `create(APIKeyResponseSchema)` to create a new message.
* Describes the message user.v1.GetAPIKeyResponse.
* Use `create(GetAPIKeyResponseSchema)` to create a new message.
*/
export const APIKeyResponseSchema: GenMessage<APIKeyResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 3);
export const GetAPIKeyResponseSchema: GenMessage<GetAPIKeyResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 6);
/**
* @generated from message user.v1.UpdateProfilePictureRequest
*/
export type UpdateProfilePictureRequest = Message<"user.v1.UpdateProfilePictureRequest"> & {
/**
* @generated from field: string file_name = 1;
*/
fileName: string;
/**
* @generated from field: bytes data = 2;
*/
data: Uint8Array;
};
/**
* Describes the message user.v1.UpdateProfilePictureRequest.
* Use `create(UpdateProfilePictureRequestSchema)` to create a new message.
*/
export const UpdateProfilePictureRequestSchema: GenMessage<UpdateProfilePictureRequest> = /*@__PURE__*/
messageDesc(file_user_v1_user, 7);
/**
* @generated from message user.v1.UpdateProfilePictureResponse
*/
export type UpdateProfilePictureResponse = Message<"user.v1.UpdateProfilePictureResponse"> & {
/**
* @generated from field: user.v1.User user = 1;
*/
user?: User;
};
/**
* Describes the message user.v1.UpdateProfilePictureResponse.
* Use `create(UpdateProfilePictureResponseSchema)` to create a new message.
*/
export const UpdateProfilePictureResponseSchema: GenMessage<UpdateProfilePictureResponse> = /*@__PURE__*/
messageDesc(file_user_v1_user, 8);
/**
* @generated from service user.v1.UserService
*/
export const UserService: GenService<{
/**
* @generated from rpc user.v1.UserService.ChangePassword
* @generated from rpc user.v1.UserService.GetUser
*/
changePassword: {
getUser: {
methodKind: "unary";
input: typeof ChangePasswordRequestSchema;
output: typeof ChangePasswordResponseSchema;
input: typeof GetUserRequestSchema;
output: typeof GetUserResponseSchema;
},
/**
* @generated from rpc user.v1.UserService.APIKey
* @generated from rpc user.v1.UserService.UpdatePassword
*/
aPIKey: {
updatePassword: {
methodKind: "unary";
input: typeof APIKeyRequestSchema;
output: typeof APIKeyResponseSchema;
input: typeof UpdatePasswordRequestSchema;
output: typeof UpdatePasswordResponseSchema;
},
/**
* @generated from rpc user.v1.UserService.GetAPIKey
*/
getAPIKey: {
methodKind: "unary";
input: typeof GetAPIKeyRequestSchema;
output: typeof GetAPIKeyResponseSchema;
},
/**
* @generated from rpc user.v1.UserService.UpdateProfilePicture
*/
updateProfilePicture: {
methodKind: "unary";
input: typeof UpdateProfilePictureRequestSchema;
output: typeof UpdateProfilePictureResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_user_v1_user, 0);

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { Button } from 'bits-ui';
import { cn } from '$lib/utils';
import type { MouseEventHandler } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
className,
type,
onclick,
children
}: {
className?: string;
type?: 'submit' | 'reset' | 'button' | null;
onclick?: () => MouseEventHandler<HTMLButtonElement> | null | undefined;
children?: Snippet<[]>;
} = $props();
</script>
<Button.Root
{type}
class={cn(
'bg-sky text-crust hover:brightness-120 w-fit cursor-pointer rounded p-2 px-4 text-sm font-medium transition-all',
className
)}
{onclick}
>
{@render children?.()}
</Button.Root>

View File

@ -7,7 +7,7 @@
trigger,
content,
open = $bindable(false)
}: { trigger: Snippet; content: Snippet; open: boolean } = $props();
}: { trigger: Snippet; content: Snippet; open?: boolean } = $props();
</script>
<Dialog.Root bind:open>

View File

@ -9,17 +9,21 @@
House,
type Icon as IconType
} from '@lucide/svelte';
import { NavigationMenu, Popover, Separator, Dialog } from 'bits-ui';
import { fly, slide } from 'svelte/transition';
import { NavigationMenu, Popover, Separator, Dialog, Avatar } from 'bits-ui';
import { fade, fly, slide } from 'svelte/transition';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { AuthClient } from '$lib/transport';
import { AuthClient, UserClient } from '$lib/transport';
import { page } from '$app/state';
import { cn } from '$lib/utils';
let { children } = $props();
const username = localStorage.getItem('username');
let user = UserClient.getUser({}).then((res) => {
return res.user;
});
let sidebarOpen = $state(false);
let popupOpen = $state(false);
type MenuItem = {
name: string;
@ -46,14 +50,17 @@
async function logout() {
await AuthClient.logout({});
localStorage.removeItem('username');
await goto('/auth');
toast.success('logged out successfully');
if (sidebarOpen) {
sidebarOpen = false;
}
}
</script>
<header
class="border-surface-0 bg-mantle fixed flex h-[50px] w-full items-center justify-between border-b p-2 px-6 drop-shadow-md"
class="border-surface-0 bg-mantle fixed z-50 flex h-[50px] w-full items-center justify-between border-b p-2 px-6 drop-shadow-md"
>
<div class="flex items-center gap-4">
<Dialog.Root bind:open={sidebarOpen}>
@ -61,7 +68,20 @@
<Menu />
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-50 mt-[50px] bg-black/50" />
<Dialog.Overlay forceMount>
{#snippet child({ props, open })}
{#if open}
<div
{...props}
transition:fade={{
duration: 150
}}
>
<div class="fixed inset-0 z-50 mt-[50px] bg-black/50"></div>
</div>
{/if}
{/snippet}
</Dialog.Overlay>
<Dialog.Content forceMount>
{#snippet child({ props, open })}
{#if open}
@ -73,7 +93,9 @@
}}
>
<NavigationMenu.Root orientation="vertical">
<NavigationMenu.List class="flex w-full flex-col gap-2 overflow-y-scroll p-2">
<NavigationMenu.List
class="flex w-full flex-col gap-2 overflow-y-auto overflow-x-hidden p-2"
>
{#each menuItems as item}
{@const Icon = item.icon}
<NavigationMenu.Item>
@ -83,7 +105,7 @@
page.url.pathname === item.href && 'bg-surface-0'
)}
href={item.href}
onclick={() => {
onSelect={() => {
if (sidebarOpen) {
sidebarOpen = false;
}
@ -101,6 +123,11 @@
<a
href="/settings"
class="hover:bg-surface-0 flex select-none items-center gap-2 rounded-lg p-2 transition-all"
onclick={() => {
if (sidebarOpen) {
sidebarOpen = false;
}
}}
>
<Settings />
<span>Settings</span>
@ -146,24 +173,36 @@
<NavigationMenu.Viewport class="absolute" />
</NavigationMenu.Root>
<Popover.Root>
<Popover.Root bind:open={popupOpen}>
<Popover.Trigger
class="border-surface-2 hover:bg-surface-0 cursor-pointer rounded border p-1 px-4 text-sm transition-all"
class="outline-surface-2 hover:brightness-120 bg-text text-crust h-9 w-9 cursor-pointer rounded-full outline outline-offset-2 text-sm transition-all"
>
{username}
{#await user then user}
<Avatar.Root class="flex h-full w-full items-center justify-center">
<Avatar.Image src={user?.profilePicture} alt={`${user?.username}'s avatar`} class="rounded-full" />
<Avatar.Fallback class="font-medium uppercase"
>{user?.username.substring(0, 2)}</Avatar.Fallback
>
</Avatar.Root>
{/await}
</Popover.Trigger>
<Popover.Content forceMount>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
class="bg-mantle border-surface-0 z-50 mt-1 rounded border drop-shadow-md transition-all"
class="bg-mantle border-surface-0 m-1 rounded border drop-shadow-md transition-all"
{...props}
transition:fly
>
<a
class="hover:bg-surface-0 flex items-center gap-1 p-3 px-4 text-sm"
href="/settings"
onclick={() => {
if (popupOpen) {
popupOpen = false;
}
}}
>
<Settings size="20" />
Settings
@ -184,6 +223,6 @@
</Popover.Root>
</header>
<div class="pt-[50px]">
<div class="pt-[50px] overflow-auto">
{@render children()}
</div>

View File

@ -1,2 +1,11 @@
<h1>Welcome to TrevStack</h1>
<p>Visit <a href="https://github.com/spotdemo4/trevstack">github.com/spotdemo4/trevstack</a> to read the documentation</p>
<div class="flex h-[calc(100vh-50px)]">
<div class="m-auto flex flex-col gap-2 p-4">
<h1 class="decoration-sky text-4xl font-bold underline underline-offset-4">
Welcome to TrevStack
</h1>
<p>
Visit <a href="https://github.com/spotdemo4/trevstack">github.com/spotdemo4/trevstack</a> to read
the documentation
</p>
</div>
</div>

View File

@ -2,11 +2,10 @@
import { ItemClient } from '$lib/transport';
import { Plus, Trash, Pencil } from '@lucide/svelte';
import { timestampFromDate, timestampDate } from '@bufbuild/protobuf/wkt';
import { Dialog, Button } from 'bits-ui';
import { fade } from 'svelte/transition';
import { toast } from 'svelte-sonner';
import { ConnectError } from '@connectrpc/connect';
import Modal from '$lib/ui/Modal.svelte';
import Button from '$lib/ui/Button.svelte';
import { SvelteMap } from 'svelte/reactivity';
// Config
@ -67,6 +66,7 @@
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="w-8"></td>
</tr>
{:then items}
{#each items as item}
@ -80,16 +80,19 @@
<td class="px-6 py-3">{item.quantity}</td>
<td class="pr-2">
<div class="flex gap-2">
<Modal bind:open={
() => editsOpen.has(item.id!) ? editsOpen.get(item.id!)! : editsOpen.set(item.id!, false) && editsOpen.get(item.id!)!,
(value) => editsOpen.set(item.id!, value)
}>
<Modal
bind:open={
() =>
editsOpen.has(item.id!)
? editsOpen.get(item.id!)!
: editsOpen.set(item.id!, false) && editsOpen.get(item.id!)!,
(value) => editsOpen.set(item.id!, value)
}
>
{#snippet trigger()}
<button
class="bg-text text-crust hover:brightness-120 block cursor-pointer rounded p-2 drop-shadow-md"
>
<Button className="bg-text">
<Pencil />
</button>
</Button>
{/snippet}
{#snippet content()}
@ -119,7 +122,7 @@
if (response.item && item.id) {
toast.success(`item "${name}" saved`);
editsOpen.set(item.id, false)
editsOpen.set(item.id, false);
await updateItems();
}
} catch (err) {
@ -169,27 +172,25 @@
value={item.quantity}
/>
</div>
<Button.Root
type="submit"
class="bg-sky text-crust hover:brightness-120 w-20 cursor-pointer rounded p-2 px-4 text-sm transition-all"
>
Submit
</Button.Root>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}
</Modal>
<Modal bind:open={
() => deletesOpen.has(item.id!) ? deletesOpen.get(item.id!)! : deletesOpen.set(item.id!, false) && deletesOpen.get(item.id!)!,
(value) => deletesOpen.set(item.id!, value)
}>
<Modal
bind:open={
() =>
deletesOpen.has(item.id!)
? deletesOpen.get(item.id!)!
: deletesOpen.set(item.id!, false) && deletesOpen.get(item.id!)!,
(value) => deletesOpen.set(item.id!, value)
}
>
{#snippet trigger()}
<button
class="bg-red text-crust hover:brightness-120 block cursor-pointer rounded p-2 drop-shadow-md"
>
<Button className="bg-red">
<Trash />
</button>
</Button>
{/snippet}
{#snippet content()}
@ -206,7 +207,7 @@
});
toast.success(`item "${item.name}" deleted`);
deletesOpen.set(item.id!, false);
deletesOpen.set(item.id!, false);
await updateItems();
} catch (err) {
const error = ConnectError.from(err);
@ -215,15 +216,11 @@
}}
>
<div class="flex flex-col gap-4 p-3">
<span class="text-center">Are you sure you want to delete "{item.name}"?</span
<span class="text-center"
>Are you sure you want to delete "{item.name}"?</span
>
<div class="flex justify-center gap-4">
<Button.Root
type="submit"
class="bg-sky text-crust hover:brightness-120 cursor-pointer rounded p-2 px-4 text-sm transition-all"
>
Confirm
</Button.Root>
<Button type="submit">Submit</Button>
</div>
</div>
</form>
@ -241,11 +238,9 @@
<div class="mx-4 mt-1 flex justify-end">
<Modal bind:open={addedOpen}>
{#snippet trigger()}
<button
class="bg-sky text-crust hover:brightness-120 cursor-pointer rounded p-2 px-4 drop-shadow-md"
>
<Button className="bg-sky">
<Plus />
</button>
</Button>
{/snippet}
{#snippet content()}
@ -319,12 +314,7 @@
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button.Root
type="submit"
class="bg-sky text-crust hover:brightness-120 w-fit cursor-pointer rounded p-2 px-4 text-sm transition-all"
>
Submit
</Button.Root>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}

View File

@ -0,0 +1,210 @@
<script lang="ts">
import { UserClient } from '$lib/transport';
import Button from '$lib/ui/Button.svelte';
import Modal from '$lib/ui/Modal.svelte';
import { ConnectError } from '@connectrpc/connect';
import { Avatar, Separator } from 'bits-ui';
import { toast } from 'svelte-sonner';
let user = UserClient.getUser({}).then((res) => {
return res.user;
});
let key = $state('');
</script>
<div class="flex h-[calc(100vh-50px)]">
<div class="m-auto flex w-96 flex-col gap-4 p-4">
{#await user then user}
<div class="flex items-center justify-center gap-4">
<div
class="outline-surface-2 bg-text text-crust h-9 w-9 select-none rounded-full outline outline-offset-2 text-sm"
>
<Avatar.Root class="flex h-full w-full items-center justify-center">
<Avatar.Image src={user?.profilePicture} alt={`${user?.username}'s avatar`} class="rounded-full" />
<Avatar.Fallback class="font-medium uppercase"
>{user?.username.substring(0, 2)}</Avatar.Fallback
>
</Avatar.Root>
</div>
<h1 class="overflow-x-hidden text-2xl font-medium">{user?.username}</h1>
</div>
{/await}
<Separator.Root class="bg-surface-0 h-px" />
<div class="flex justify-around gap-2">
<Modal>
{#snippet trigger()}
<Button className="bg-text">Generate API Key</Button>
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Generate API Key
</h1>
{#if key == ''}
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
try {
const response = await UserClient.getAPIKey({
password: formData.get('password')?.toString(),
confirmPassword: formData.get('confirm-password')?.toString()
});
if (response.key) {
key = response.key;
form.reset();
}
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="password" class="text-sm">Password</label>
<input
id="password"
name="password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<div class="flex flex-col gap-1">
<label for="confirm-password" class="text-sm">Confirm Password</label>
<input
id="confirm-password"
name="confirm-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{:else}
<div class="p-3">
<span class="text-wrap break-all">{key}</span>
</div>
{/if}
{/snippet}
</Modal>
<Modal>
{#snippet trigger()}
<Button className="bg-text">Change Profile Picture</Button>
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Change Profile Picture
</h1>
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
let fileInput = document.getElementById('file') as HTMLInputElement;
let file = fileInput.files?.[0];
if (!file) {
toast.error('No file selected');
return;
}
const data = await file.bytes();
try {
const response = await UserClient.updateProfilePicture({
fileName: file.name,
data: data,
});
if (response.user) {
toast.success('Profile picture updated');
form.reset();
}
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="file" class="text-sm">Profile Picture</label>
<input
id="file"
name="file"
type="file"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}
</Modal>
</div>
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
try {
await UserClient.updatePassword({
oldPassword: formData.get('old-password')?.toString(),
newPassword: formData.get('new-password')?.toString(),
confirmPassword: formData.get('confirm-password')?.toString()
});
toast.success('password updated successfully');
form.reset();
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
class="bg-mantle border-surface-0 rounded border p-4 drop-shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="old-password" class="text-sm">Old Password</label>
<input
id="old-password"
name="old-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<div class="flex flex-col gap-1">
<label for="new-password" class="text-sm">New Password</label>
<input
id="new-password"
name="new-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<div class="flex flex-col gap-1">
<label for="confirm-password" class="text-sm">Confirm New Password</label>
<input
id="confirm-password"
name="confirm-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button type="submit">Submit</Button>
</div>
</form>
</div>
</div>

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { Tabs, Button } from 'bits-ui';
import { Tabs } from 'bits-ui';
import { cn } from '$lib/utils';
import { AuthClient } from '$lib/transport';
import { goto } from '$app/navigation';
import { ConnectError } from '@connectrpc/connect';
import { toast } from 'svelte-sonner';
import Button from '$lib/ui/Button.svelte';
let tab = $state('login');
</script>
@ -47,7 +48,6 @@
});
if (response.token && username) {
localStorage.setItem('username', username);
goto('/');
}
} catch (err) {
@ -75,12 +75,7 @@
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button.Root
type="submit"
class="bg-sky text-crust hover:brightness-120 w-20 cursor-pointer rounded p-2 px-4 text-sm transition-all"
>
Submit
</Button.Root>
<Button type="submit">Submit</Button>
</div>
</form>
</Tabs.Content>
@ -138,12 +133,7 @@
class="border-surface-0 rounded border p-2 text-sm"
/>
</div>
<Button.Root
type="submit"
class="bg-sky text-crust hover:brightness-120 w-20 cursor-pointer rounded p-2 px-4 text-sm transition-all"
>
Submit
</Button.Root>
<Button type="submit">Submit</Button>
</div>
</form>
</Tabs.Content>

View File

@ -337,7 +337,7 @@ components:
type: object
title: SignUpResponse
additionalProperties: false
user.v1.APIKeyRequest:
user.v1.GetAPIKeyRequest:
type: object
properties:
password:
@ -346,17 +346,29 @@ components:
confirmPassword:
type: string
title: confirm_password
title: APIKeyRequest
title: GetAPIKeyRequest
additionalProperties: false
user.v1.APIKeyResponse:
user.v1.GetAPIKeyResponse:
type: object
properties:
key:
type: string
title: key
title: APIKeyResponse
title: GetAPIKeyResponse
additionalProperties: false
user.v1.ChangePasswordRequest:
user.v1.GetUserRequest:
type: object
title: GetUserRequest
additionalProperties: false
user.v1.GetUserResponse:
type: object
properties:
user:
title: user
$ref: '#/components/schemas/user.v1.User'
title: GetUserResponse
additionalProperties: false
user.v1.UpdatePasswordRequest:
type: object
properties:
oldPassword:
@ -368,11 +380,50 @@ components:
confirmPassword:
type: string
title: confirm_password
title: ChangePasswordRequest
title: UpdatePasswordRequest
additionalProperties: false
user.v1.ChangePasswordResponse:
user.v1.UpdatePasswordResponse:
type: object
title: ChangePasswordResponse
properties:
user:
title: user
$ref: '#/components/schemas/user.v1.User'
title: UpdatePasswordResponse
additionalProperties: false
user.v1.UpdateProfilePictureRequest:
type: object
properties:
fileName:
type: string
title: file_name
data:
type: string
title: data
format: byte
title: UpdateProfilePictureRequest
additionalProperties: false
user.v1.UpdateProfilePictureResponse:
type: object
properties:
user:
title: user
$ref: '#/components/schemas/user.v1.User'
title: UpdateProfilePictureResponse
additionalProperties: false
user.v1.User:
type: object
properties:
id:
type: integer
title: id
username:
type: string
title: username
profilePicture:
type: string
title: profile_picture
nullable: true
title: User
additionalProperties: false
security:
- bearerAuth: []
@ -657,12 +708,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/user.v1.LogoutResponse'
/user.v1.UserService/ChangePassword:
/user.v1.UserService/GetUser:
post:
tags:
- user.v1.UserService
summary: ChangePassword
operationId: user.v1.UserService.ChangePassword
summary: GetUser
operationId: user.v1.UserService.GetUser
parameters:
- name: Connect-Protocol-Version
in: header
@ -677,7 +728,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.ChangePasswordRequest'
$ref: '#/components/schemas/user.v1.GetUserRequest'
required: true
responses:
default:
@ -691,13 +742,13 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.ChangePasswordResponse'
/user.v1.UserService/APIKey:
$ref: '#/components/schemas/user.v1.GetUserResponse'
/user.v1.UserService/UpdatePassword:
post:
tags:
- user.v1.UserService
summary: APIKey
operationId: user.v1.UserService.APIKey
summary: UpdatePassword
operationId: user.v1.UserService.UpdatePassword
parameters:
- name: Connect-Protocol-Version
in: header
@ -712,7 +763,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.APIKeyRequest'
$ref: '#/components/schemas/user.v1.UpdatePasswordRequest'
required: true
responses:
default:
@ -726,7 +777,77 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.APIKeyResponse'
$ref: '#/components/schemas/user.v1.UpdatePasswordResponse'
/user.v1.UserService/GetAPIKey:
post:
tags:
- user.v1.UserService
summary: GetAPIKey
operationId: user.v1.UserService.GetAPIKey
parameters:
- name: Connect-Protocol-Version
in: header
required: true
schema:
$ref: '#/components/schemas/connect-protocol-version'
- name: Connect-Timeout-Ms
in: header
schema:
$ref: '#/components/schemas/connect-timeout-header'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.GetAPIKeyRequest'
required: true
responses:
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/connect.error'
"200":
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.GetAPIKeyResponse'
/user.v1.UserService/UpdateProfilePicture:
post:
tags:
- user.v1.UserService
summary: UpdateProfilePicture
operationId: user.v1.UserService.UpdateProfilePicture
parameters:
- name: Connect-Protocol-Version
in: header
required: true
schema:
$ref: '#/components/schemas/connect-protocol-version'
- name: Connect-Timeout-Ms
in: header
schema:
$ref: '#/components/schemas/connect-timeout-header'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.UpdateProfilePictureRequest'
required: true
responses:
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/connect.error'
"200":
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/user.v1.UpdateProfilePictureResponse'
tags:
- name: item.v1.ItemService
- name: user.v1.AuthService

View File

@ -13,6 +13,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/file': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
host: '0.0.0.0',
}