Thomas G. Lopes commited on
Commit
79e869e
·
1 Parent(s): 9ce1fcb

use dropdown in billing modal

Browse files
src/lib/components/inference-playground/billing-modal.svelte CHANGED
@@ -1,6 +1,13 @@
1
  <script lang="ts">
2
  import { billing } from "$lib/state/billing.svelte";
 
 
 
 
3
  import Dialog from "../dialog.svelte";
 
 
 
4
 
5
  interface Props {
6
  onClose: () => void;
@@ -8,22 +15,52 @@
8
 
9
  const { onClose }: Props = $props();
10
 
11
- let inputValue = $state(billing.organization);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  function handleSubmit(e: Event) {
14
  e.preventDefault();
15
- const form = e.target as HTMLFormElement;
16
- const formData = new FormData(form);
17
- const org = (formData.get("billing-org") as string).trim() ?? "";
18
- billing.organization = org;
19
- inputValue = org;
20
  onClose();
21
  }
22
 
23
- function handleReset() {
24
- billing.reset();
25
- inputValue = "";
26
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  </script>
28
 
29
  <Dialog title="Billing Settings" open={true} {onClose}>
@@ -31,16 +68,97 @@
31
  <form onsubmit={handleSubmit} class="space-y-4">
32
  <div>
33
  <div class="mb-2 flex items-center gap-2">
34
- <label for="billing-org" class="text-sm font-medium text-gray-900 dark:text-white"> Organization Name </label>
 
 
35
  </div>
36
- <input
37
- type="text"
38
- id="billing-org"
39
- name="billing-org"
40
- bind:value={inputValue}
41
- placeholder="my-org-name"
42
- class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-3 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
43
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  </div>
45
 
46
  <!-- Current Status -->
@@ -58,11 +176,28 @@
58
  <div class="rounded-lg bg-green-50 p-3 dark:bg-green-900/20">
59
  <div class="flex items-center gap-3">
60
  {#if billing.organizationInfo.avatar}
61
- <img
62
- src={billing.organizationInfo.avatar}
63
- alt={billing.organizationInfo.displayName}
64
- class="h-8 w-8 rounded-full"
65
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  {/if}
67
  <div class="flex-1">
68
  <div class="flex items-center gap-2">
@@ -103,7 +238,7 @@
103
  {#if billing.organization}
104
  <button
105
  type="button"
106
- onclick={handleReset}
107
  class="rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
108
  >
109
  Reset
@@ -131,3 +266,22 @@
131
  </div>
132
  </div>
133
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <script lang="ts">
2
  import { billing } from "$lib/state/billing.svelte";
3
+ import { user } from "$lib/state/user.svelte";
4
+ import { cn } from "$lib/utils/cn.js";
5
+ import { Select } from "melt/builders";
6
+ import IconCaret from "~icons/carbon/chevron-down";
7
  import Dialog from "../dialog.svelte";
8
+ import { Avatar } from "melt/components";
9
+ import { omit } from "$lib/utils/object.svelte";
10
+ import IconLoading from "~icons/line-md/loading-loop";
11
 
12
  interface Props {
13
  onClose: () => void;
 
15
 
16
  const { onClose }: Props = $props();
17
 
18
+ const options = $derived([
19
+ { value: "", label: "Personal Account", avatarUrl: user.avatarUrl, isEnterprise: false },
20
+ ...user.orgs.map(org => ({
21
+ value: org.name,
22
+ label: org.fullname,
23
+ avatarUrl: org.avatarUrl,
24
+ isEnterprise: org.isEnterprise,
25
+ })),
26
+ ]);
27
+
28
+ const select = new Select({
29
+ value: () => billing.organization,
30
+ onValueChange(v) {
31
+ if (v !== undefined) {
32
+ billing.organization = v;
33
+ }
34
+ },
35
+ sameWidth: true,
36
+ });
37
 
38
  function handleSubmit(e: Event) {
39
  e.preventDefault();
 
 
 
 
 
40
  onClose();
41
  }
42
 
43
+ const getInitials = (username: string): string => {
44
+ // Handle empty strings
45
+ if (!username) return "";
46
+
47
+ // Split by common separators and handle camelCase/PascalCase
48
+ const parts = username
49
+ // Insert space before capitals in camelCase/PascalCase
50
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
51
+ // Split by common separators
52
+ .split(/[\s\-_/.]+/)
53
+ // Remove empty parts
54
+ .filter(part => part.length > 0);
55
+
56
+ // Get first letter of first part
57
+ const firstInitial = parts[0]?.[0]?.toUpperCase() || "";
58
+
59
+ // Get first letter of last part if different from first part
60
+ const lastInitial = parts.length > 1 ? parts[parts.length - 1]?.[0]?.toUpperCase() : "";
61
+
62
+ return firstInitial + (lastInitial === firstInitial ? "" : lastInitial);
63
+ };
64
  </script>
65
 
66
  <Dialog title="Billing Settings" open={true} {onClose}>
 
68
  <form onsubmit={handleSubmit} class="space-y-4">
69
  <div>
70
  <div class="mb-2 flex items-center gap-2">
71
+ <label for="billing-org" class="text-sm font-medium text-gray-900 dark:text-white">
72
+ Billing Organization
73
+ </label>
74
  </div>
75
+
76
+ {#if user.loading}
77
+ <div
78
+ class="flex items-center gap-2 rounded-lg border border-gray-300 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
79
+ >
80
+ <div
81
+ class="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900 dark:border-gray-600 dark:border-t-white"
82
+ ></div>
83
+ <span class="text-sm text-gray-600 dark:text-gray-400">Loading organizations...</span>
84
+ </div>
85
+ {:else}
86
+ <button
87
+ {...select.trigger}
88
+ type="button"
89
+ class={cn(
90
+ "relative flex w-full items-center justify-between gap-3 rounded-lg border border-gray-300 bg-gray-50 p-3 text-left text-sm",
91
+ "hover:bg-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500",
92
+ "dark:border-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:border-blue-500 dark:focus:ring-blue-500",
93
+ )}
94
+ >
95
+ {#each options as option}
96
+ {#if option.value === billing.organization}
97
+ <div class="flex items-center gap-3">
98
+ {#if option.avatarUrl}
99
+ <img src={option.avatarUrl} alt="" class="h-6 w-6 rounded-full" />
100
+ {/if}
101
+ <span class="text-gray-900 dark:text-white">
102
+ {option.label}
103
+ </span>
104
+ {#if option.isEnterprise}
105
+ <span
106
+ class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
107
+ >
108
+ Enterprise
109
+ </span>
110
+ {/if}
111
+ </div>
112
+ {/if}
113
+ {/each}
114
+ <div class="flex-none text-gray-500 dark:text-gray-400">
115
+ <IconCaret />
116
+ </div>
117
+ </button>
118
+
119
+ <div
120
+ {...select.content}
121
+ class="z-50 max-h-60 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
122
+ >
123
+ {#each options as option (option.value)}
124
+ {@const optionProps = select.getOption(option.value)}
125
+ <div {...optionProps} class="group cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700">
126
+ <div class="flex items-center gap-3">
127
+ {#if option.avatarUrl}
128
+ <Avatar src={option.avatarUrl}>
129
+ {#snippet children(avatar)}
130
+ <div
131
+ class="relative flex size-6 items-center justify-center overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-100"
132
+ >
133
+ <img
134
+ {...avatar.image}
135
+ alt="{option.label}'s avatar"
136
+ class={[
137
+ "absolute inset-0 !block h-full w-full rounded-[inherit] ",
138
+ avatar.loadingStatus === "loaded" ? "" : "invisible",
139
+ ]}
140
+ />
141
+ <span {...avatar.fallback} class="!block text-4xl font-medium text-neutral-700">
142
+ {getInitials(option.label)}
143
+ </span>
144
+ </div>
145
+ {/snippet}
146
+ </Avatar>
147
+ {/if}
148
+
149
+ <span class="text-sm text-gray-900 dark:text-white">{option.label}</span>
150
+ {#if option.isEnterprise}
151
+ <span
152
+ class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
153
+ >
154
+ Enterprise
155
+ </span>
156
+ {/if}
157
+ </div>
158
+ </div>
159
+ {/each}
160
+ </div>
161
+ {/if}
162
  </div>
163
 
164
  <!-- Current Status -->
 
176
  <div class="rounded-lg bg-green-50 p-3 dark:bg-green-900/20">
177
  <div class="flex items-center gap-3">
178
  {#if billing.organizationInfo.avatar}
179
+ <Avatar src={billing.organizationInfo.avatar}>
180
+ {#snippet children(avatar)}
181
+ <div
182
+ class="relative flex size-8 items-center justify-center overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-800"
183
+ >
184
+ <img
185
+ {...avatar.image}
186
+ alt="{billing.organizationInfo?.displayName}'s avatar"
187
+ class={[
188
+ "absolute inset-0 !block h-full w-full rounded-[inherit] ",
189
+ avatar.loadingStatus === "loaded" ? "fade-in" : "invisible",
190
+ ]}
191
+ />
192
+ <span
193
+ {...omit(avatar.fallback, "style", "hidden")}
194
+ class="!block text-sm font-medium text-neutral-200"
195
+ >
196
+ <IconLoading />
197
+ </span>
198
+ </div>
199
+ {/snippet}
200
+ </Avatar>
201
  {/if}
202
  <div class="flex-1">
203
  <div class="flex items-center gap-2">
 
238
  {#if billing.organization}
239
  <button
240
  type="button"
241
+ onclick={() => billing.reset()}
242
  class="rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
243
  >
244
  Reset
 
266
  </div>
267
  </div>
268
  </Dialog>
269
+
270
+ <style>
271
+ .fade-in {
272
+ animation: fade-in 0.25s ease-in-out;
273
+ }
274
+
275
+ @keyframes fade-in {
276
+ from {
277
+ opacity: 0;
278
+ filter: blur(10px);
279
+ scale: 1.2;
280
+ }
281
+ to {
282
+ opacity: 1;
283
+ filter: blur(0);
284
+ scale: 1;
285
+ }
286
+ }
287
+ </style>
src/lib/state/user.svelte.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { token } from "./token.svelte.js";
2
+
3
+ interface Org {
4
+ type: "org";
5
+ id: string;
6
+ name: string;
7
+ fullname: string;
8
+ avatarUrl: string;
9
+ roleInOrg: string;
10
+ isEnterprise: boolean;
11
+ }
12
+
13
+ interface WhoamiResponse {
14
+ type: "user";
15
+ id: string;
16
+ name: string;
17
+ fullname: string;
18
+ email: string;
19
+ avatarUrl: string;
20
+ orgs: Org[];
21
+ }
22
+
23
+ class User {
24
+ #info = $state<WhoamiResponse | null>(null);
25
+ #loading = $state(false);
26
+ #error = $state<string | null>(null);
27
+
28
+ constructor() {
29
+ $effect.root(() => {
30
+ $effect(() => {
31
+ if (token.value) {
32
+ this.fetchUserInfo();
33
+ } else {
34
+ this.#info = null;
35
+ }
36
+ });
37
+ });
38
+ }
39
+
40
+ get info() {
41
+ return this.#info;
42
+ }
43
+
44
+ get loading() {
45
+ return this.#loading;
46
+ }
47
+
48
+ get error() {
49
+ return this.#error;
50
+ }
51
+
52
+ get orgs() {
53
+ return this.#info?.orgs ?? [];
54
+ }
55
+
56
+ get name() {
57
+ return this.#info?.name ?? "";
58
+ }
59
+
60
+ get fullname() {
61
+ return this.#info?.fullname ?? "";
62
+ }
63
+
64
+ get avatarUrl() {
65
+ return this.#info?.avatarUrl ?? "";
66
+ }
67
+
68
+ async fetchUserInfo() {
69
+ if (!token.value) {
70
+ this.#info = null;
71
+ return;
72
+ }
73
+
74
+ this.#loading = true;
75
+ this.#error = null;
76
+
77
+ try {
78
+ const response = await fetch("https://huggingface.co/api/whoami-v2", {
79
+ headers: {
80
+ Authorization: `Bearer ${token.value}`,
81
+ },
82
+ });
83
+
84
+ if (!response.ok) {
85
+ throw new Error(`Failed to fetch user info: ${response.statusText}`);
86
+ }
87
+
88
+ this.#info = await response.json();
89
+ } catch (error) {
90
+ this.#error = error instanceof Error ? error.message : "Unknown error";
91
+ console.error("Failed to fetch user info:", error);
92
+ } finally {
93
+ this.#loading = false;
94
+ }
95
+ }
96
+
97
+ reset() {
98
+ this.#info = null;
99
+ this.#error = null;
100
+ }
101
+ }
102
+
103
+ export const user = new User();