+
+
+
+
+ {#if title}
+
+ {title}
+
+ {/if}
+
+ {#if description}
+
{description}
+ {/if}
+
+ {@render bottom?.()}
+
diff --git a/webapp/src/modules/components/ui-custom/fileManager.svelte b/webapp/src/modules/components/ui-custom/fileManager.svelte
new file mode 100644
index 00000000..56ab9cbf
--- /dev/null
+++ b/webapp/src/modules/components/ui-custom/fileManager.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
+ {@render child?.({ addFiles })}
+
+ {#if (Array.isArray(data) && data.length > 0) || (!multiple && Boolean(data))}
+
+
+
+
+ {#snippet children({ item, removeItem })}
+ {@const isNew = isNewFile(item)}
+
+
+
+ {item.name}
+
+ {#if isNew}
+ {m.New()}
+ {/if}
+
+
+ {/snippet}
+
+
+ {/if}
+
+ {#if rejectedFiles.length > 0}
+
+
+ {m.Rejected_files()}
+
+
+ {m.Clear()}
+
+
+ {#each rejectedFiles as rejection}
+
+
{rejection.file.name}
+
+ {#each rejection.reasons as reason}
+
+ {reason}
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
diff --git a/webapp/src/modules/components/ui-custom/icon.svelte b/webapp/src/modules/components/ui-custom/icon.svelte
new file mode 100644
index 00000000..4d6fb469
--- /dev/null
+++ b/webapp/src/modules/components/ui-custom/icon.svelte
@@ -0,0 +1,18 @@
+
+
+) {
diff --git a/webapp/src/modules/components/ui/accordion/accordion-content.svelte b/webapp/src/modules/components/ui/accordion/accordion-content.svelte
new file mode 100644
index 00000000..905779c0
--- /dev/null
+++ b/webapp/src/modules/components/ui/accordion/accordion-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/accordion/accordion-item.svelte b/webapp/src/modules/components/ui/accordion/accordion-item.svelte
new file mode 100644
index 00000000..adc47d99
--- /dev/null
+++ b/webapp/src/modules/components/ui/accordion/accordion-item.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/accordion/accordion-trigger.svelte b/webapp/src/modules/components/ui/accordion/accordion-trigger.svelte
new file mode 100644
index 00000000..c741d8c9
--- /dev/null
+++ b/webapp/src/modules/components/ui/accordion/accordion-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ svg]:rotate-180",
+ className
+ )}
+ {...restProps}
+ >
+ {@render children?.()}
+
+
+
diff --git a/webapp/src/modules/components/ui/accordion/index.ts b/webapp/src/modules/components/ui/accordion/index.ts
new file mode 100644
index 00000000..ed492138
--- /dev/null
+++ b/webapp/src/modules/components/ui/accordion/index.ts
@@ -0,0 +1,17 @@
+import { Accordion as AccordionPrimitive } from "bits-ui";
+import Content from "./accordion-content.svelte";
+import Item from "./accordion-item.svelte";
+import Trigger from "./accordion-trigger.svelte";
+const Root = AccordionPrimitive.Root;
+
+export {
+ Root,
+ Content,
+ Item,
+ Trigger,
+ //
+ Root as Accordion,
+ Content as AccordionContent,
+ Item as AccordionItem,
+ Trigger as AccordionTrigger,
+};
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-action.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 00000000..a165e060
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-cancel.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 00000000..6218fb9f
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-content.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 00000000..5434a852
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-description.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 00000000..0d54134f
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-footer.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 00000000..aefd43dd
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-header.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 00000000..96d9b990
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-overlay.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 00000000..4e6c19c2
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/alert-dialog-title.svelte b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 00000000..fc5ed23a
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/alert-dialog/index.ts b/webapp/src/modules/components/ui/alert-dialog/index.ts
new file mode 100644
index 00000000..d06201d4
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert-dialog/index.ts
@@ -0,0 +1,39 @@
+import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+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";
+
+const Root = AlertDialogPrimitive.Root;
+const Trigger = AlertDialogPrimitive.Trigger;
+const Portal = AlertDialogPrimitive.Portal;
+
+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,
+};
diff --git a/webapp/src/modules/components/ui/alert/alert-description.svelte b/webapp/src/modules/components/ui/alert/alert-description.svelte
new file mode 100644
index 00000000..65047ebe
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert/alert-description.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/alert/alert-title.svelte b/webapp/src/modules/components/ui/alert/alert-title.svelte
new file mode 100644
index 00000000..e7393712
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert/alert-title.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/alert/alert.svelte b/webapp/src/modules/components/ui/alert/alert.svelte
new file mode 100644
index 00000000..b913f2f3
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert/alert.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/alert/index.ts b/webapp/src/modules/components/ui/alert/index.ts
new file mode 100644
index 00000000..97e21b4e
--- /dev/null
+++ b/webapp/src/modules/components/ui/alert/index.ts
@@ -0,0 +1,14 @@
+import Root from "./alert.svelte";
+import Description from "./alert-description.svelte";
+import Title from "./alert-title.svelte";
+export { alertVariants, type AlertVariant } from "./alert.svelte";
+
+export {
+ Root,
+ Description,
+ Title,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle,
+};
diff --git a/webapp/src/modules/components/ui/avatar/avatar-fallback.svelte b/webapp/src/modules/components/ui/avatar/avatar-fallback.svelte
new file mode 100644
index 00000000..8b7a944d
--- /dev/null
+++ b/webapp/src/modules/components/ui/avatar/avatar-fallback.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/avatar/avatar-image.svelte b/webapp/src/modules/components/ui/avatar/avatar-image.svelte
new file mode 100644
index 00000000..89932b1f
--- /dev/null
+++ b/webapp/src/modules/components/ui/avatar/avatar-image.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/avatar/avatar.svelte b/webapp/src/modules/components/ui/avatar/avatar.svelte
new file mode 100644
index 00000000..27aeab29
--- /dev/null
+++ b/webapp/src/modules/components/ui/avatar/avatar.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/avatar/index.ts b/webapp/src/modules/components/ui/avatar/index.ts
new file mode 100644
index 00000000..d06457be
--- /dev/null
+++ b/webapp/src/modules/components/ui/avatar/index.ts
@@ -0,0 +1,13 @@
+import Root from "./avatar.svelte";
+import Image from "./avatar-image.svelte";
+import Fallback from "./avatar-fallback.svelte";
+
+export {
+ Root,
+ Image,
+ Fallback,
+ //
+ Root as Avatar,
+ Image as AvatarImage,
+ Fallback as AvatarFallback,
+};
diff --git a/webapp/src/modules/components/ui/badge/badge.svelte b/webapp/src/modules/components/ui/badge/badge.svelte
new file mode 100644
index 00000000..0c3a9274
--- /dev/null
+++ b/webapp/src/modules/components/ui/badge/badge.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/badge/index.ts b/webapp/src/modules/components/ui/badge/index.ts
new file mode 100644
index 00000000..64e0aa9b
--- /dev/null
+++ b/webapp/src/modules/components/ui/badge/index.ts
@@ -0,0 +1,2 @@
+export { default as Badge } from "./badge.svelte";
+export { badgeVariants, type BadgeVariant } from "./badge.svelte";
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
new file mode 100644
index 00000000..55783644
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+ More
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-item.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-item.svelte
new file mode 100644
index 00000000..9686220a
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-item.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-link.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-link.svelte
new file mode 100644
index 00000000..72b6d927
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-link.svelte
@@ -0,0 +1,31 @@
+
+
+{#if child}
+ {@render child({ props: attrs })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-list.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-list.svelte
new file mode 100644
index 00000000..37d9fd3d
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-list.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-page.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-page.svelte
new file mode 100644
index 00000000..02576cc5
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-page.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb-separator.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-separator.svelte
new file mode 100644
index 00000000..451e217f
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb-separator.svelte
@@ -0,0 +1,27 @@
+
+
+svg]:size-3.5", className)}
+ bind:this={ref}
+ {...restProps}
+>
+ {#if children}
+ {@render children?.()}
+ {:else}
+
+ {/if}
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/breadcrumb.svelte b/webapp/src/modules/components/ui/breadcrumb/breadcrumb.svelte
new file mode 100644
index 00000000..70be14df
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/breadcrumb.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/breadcrumb/index.ts b/webapp/src/modules/components/ui/breadcrumb/index.ts
new file mode 100644
index 00000000..dc914ec3
--- /dev/null
+++ b/webapp/src/modules/components/ui/breadcrumb/index.ts
@@ -0,0 +1,25 @@
+import Root from "./breadcrumb.svelte";
+import Ellipsis from "./breadcrumb-ellipsis.svelte";
+import Item from "./breadcrumb-item.svelte";
+import Separator from "./breadcrumb-separator.svelte";
+import Link from "./breadcrumb-link.svelte";
+import List from "./breadcrumb-list.svelte";
+import Page from "./breadcrumb-page.svelte";
+
+export {
+ Root,
+ Ellipsis,
+ Item,
+ Separator,
+ Link,
+ List,
+ Page,
+ //
+ Root as Breadcrumb,
+ Ellipsis as BreadcrumbEllipsis,
+ Item as BreadcrumbItem,
+ Separator as BreadcrumbSeparator,
+ Link as BreadcrumbLink,
+ List as BreadcrumbList,
+ Page as BreadcrumbPage,
+};
diff --git a/webapp/src/modules/components/ui/button/button.svelte b/webapp/src/modules/components/ui/button/button.svelte
new file mode 100644
index 00000000..2ff6a02b
--- /dev/null
+++ b/webapp/src/modules/components/ui/button/button.svelte
@@ -0,0 +1,74 @@
+
+
+
+
+{#if href}
+
+ {@render children?.()}
+
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/button/index.ts b/webapp/src/modules/components/ui/button/index.ts
new file mode 100644
index 00000000..fb585d76
--- /dev/null
+++ b/webapp/src/modules/components/ui/button/index.ts
@@ -0,0 +1,17 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+} from "./button.svelte";
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/webapp/src/modules/components/ui/calendar/calendar-cell.svelte b/webapp/src/modules/components/ui/calendar/calendar-cell.svelte
new file mode 100644
index 00000000..878fb0c0
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-cell.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-day.svelte b/webapp/src/modules/components/ui/calendar/calendar-day.svelte
new file mode 100644
index 00000000..d4996faa
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-day.svelte
@@ -0,0 +1,30 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-grid-body.svelte b/webapp/src/modules/components/ui/calendar/calendar-grid-body.svelte
new file mode 100644
index 00000000..8c7d3540
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-grid-body.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-grid-head.svelte b/webapp/src/modules/components/ui/calendar/calendar-grid-head.svelte
new file mode 100644
index 00000000..038db460
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-grid-head.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-grid-row.svelte b/webapp/src/modules/components/ui/calendar/calendar-grid-row.svelte
new file mode 100644
index 00000000..a2db02c5
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-grid-row.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-grid.svelte b/webapp/src/modules/components/ui/calendar/calendar-grid.svelte
new file mode 100644
index 00000000..d69438c5
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-grid.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-head-cell.svelte b/webapp/src/modules/components/ui/calendar/calendar-head-cell.svelte
new file mode 100644
index 00000000..b24bee80
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-head-cell.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-header.svelte b/webapp/src/modules/components/ui/calendar/calendar-header.svelte
new file mode 100644
index 00000000..6039c4e1
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-header.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-heading.svelte b/webapp/src/modules/components/ui/calendar/calendar-heading.svelte
new file mode 100644
index 00000000..83d2dba9
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-heading.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-months.svelte b/webapp/src/modules/components/ui/calendar/calendar-months.svelte
new file mode 100644
index 00000000..68a8918b
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-months.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-next-button.svelte b/webapp/src/modules/components/ui/calendar/calendar-next-button.svelte
new file mode 100644
index 00000000..5a2cb4cc
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-next-button.svelte
@@ -0,0 +1,28 @@
+
+
+{#snippet Fallback()}
+
+{/snippet}
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar-prev-button.svelte b/webapp/src/modules/components/ui/calendar/calendar-prev-button.svelte
new file mode 100644
index 00000000..26983bc8
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar-prev-button.svelte
@@ -0,0 +1,28 @@
+
+
+{#snippet Fallback()}
+
+{/snippet}
+
+
diff --git a/webapp/src/modules/components/ui/calendar/calendar.svelte b/webapp/src/modules/components/ui/calendar/calendar.svelte
new file mode 100644
index 00000000..da82ce8b
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/calendar.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+ {#snippet children({ months, weekdays })}
+
+
+
+
+
+
+ {#each months as month}
+
+
+
+ {#each weekdays as weekday}
+
+ {weekday.slice(0, 2)}
+
+ {/each}
+
+
+
+ {#each month.weeks as weekDates}
+
+ {#each weekDates as date}
+
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/calendar/index.ts b/webapp/src/modules/components/ui/calendar/index.ts
new file mode 100644
index 00000000..ab257ab3
--- /dev/null
+++ b/webapp/src/modules/components/ui/calendar/index.ts
@@ -0,0 +1,30 @@
+import Root from "./calendar.svelte";
+import Cell from "./calendar-cell.svelte";
+import Day from "./calendar-day.svelte";
+import Grid from "./calendar-grid.svelte";
+import Header from "./calendar-header.svelte";
+import Months from "./calendar-months.svelte";
+import GridRow from "./calendar-grid-row.svelte";
+import Heading from "./calendar-heading.svelte";
+import GridBody from "./calendar-grid-body.svelte";
+import GridHead from "./calendar-grid-head.svelte";
+import HeadCell from "./calendar-head-cell.svelte";
+import NextButton from "./calendar-next-button.svelte";
+import PrevButton from "./calendar-prev-button.svelte";
+
+export {
+ Day,
+ Cell,
+ Grid,
+ Header,
+ Months,
+ GridRow,
+ Heading,
+ GridBody,
+ GridHead,
+ HeadCell,
+ NextButton,
+ PrevButton,
+ //
+ Root as Calendar,
+};
diff --git a/webapp/src/modules/components/ui/card/card-content.svelte b/webapp/src/modules/components/ui/card/card-content.svelte
new file mode 100644
index 00000000..a921881d
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card-content.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/card-description.svelte b/webapp/src/modules/components/ui/card/card-description.svelte
new file mode 100644
index 00000000..68840d3d
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card-description.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/card-footer.svelte b/webapp/src/modules/components/ui/card/card-footer.svelte
new file mode 100644
index 00000000..cb2a778b
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card-footer.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/card-header.svelte b/webapp/src/modules/components/ui/card/card-header.svelte
new file mode 100644
index 00000000..2ea43455
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card-header.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/card-title.svelte b/webapp/src/modules/components/ui/card/card-title.svelte
new file mode 100644
index 00000000..f6157e42
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card-title.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/card.svelte b/webapp/src/modules/components/ui/card/card.svelte
new file mode 100644
index 00000000..19356b07
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/card.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/card/index.ts b/webapp/src/modules/components/ui/card/index.ts
new file mode 100644
index 00000000..0f9084d1
--- /dev/null
+++ b/webapp/src/modules/components/ui/card/index.ts
@@ -0,0 +1,22 @@
+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";
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+};
diff --git a/webapp/src/modules/components/ui/checkbox/checkbox.svelte b/webapp/src/modules/components/ui/checkbox/checkbox.svelte
new file mode 100644
index 00000000..619040a8
--- /dev/null
+++ b/webapp/src/modules/components/ui/checkbox/checkbox.svelte
@@ -0,0 +1,35 @@
+
+
+
+ {#snippet children({ checked, indeterminate })}
+
+ {#if indeterminate}
+
+ {:else}
+
+ {/if}
+
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/checkbox/index.ts b/webapp/src/modules/components/ui/checkbox/index.ts
new file mode 100644
index 00000000..6d92d945
--- /dev/null
+++ b/webapp/src/modules/components/ui/checkbox/index.ts
@@ -0,0 +1,6 @@
+import Root from "./checkbox.svelte";
+export {
+ Root,
+ //
+ Root as Checkbox,
+};
diff --git a/webapp/src/modules/components/ui/collapsible/index.ts b/webapp/src/modules/components/ui/collapsible/index.ts
new file mode 100644
index 00000000..83c01988
--- /dev/null
+++ b/webapp/src/modules/components/ui/collapsible/index.ts
@@ -0,0 +1,15 @@
+import { Collapsible as CollapsiblePrimitive } from "bits-ui";
+
+const Root = CollapsiblePrimitive.Root;
+const Trigger = CollapsiblePrimitive.Trigger;
+const Content = CollapsiblePrimitive.Content;
+
+export {
+ Root,
+ Content,
+ Trigger,
+ //
+ Root as Collapsible,
+ Content as CollapsibleContent,
+ Trigger as CollapsibleTrigger,
+};
diff --git a/webapp/src/modules/components/ui/dialog/dialog-content.svelte b/webapp/src/modules/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 00000000..81d0fd92
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/webapp/src/modules/components/ui/dialog/dialog-description.svelte b/webapp/src/modules/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 00000000..58101626
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dialog/dialog-footer.svelte b/webapp/src/modules/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 00000000..aefd43dd
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/dialog/dialog-header.svelte b/webapp/src/modules/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 00000000..be39ee86
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/dialog/dialog-overlay.svelte b/webapp/src/modules/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 00000000..2c94e3f7
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dialog/dialog-title.svelte b/webapp/src/modules/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 00000000..030d71e4
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dialog/index.ts b/webapp/src/modules/components/ui/dialog/index.ts
new file mode 100644
index 00000000..3286ab7a
--- /dev/null
+++ b/webapp/src/modules/components/ui/dialog/index.ts
@@ -0,0 +1,37 @@
+import { Dialog as DialogPrimitive } from "bits-ui";
+
+import Title from "./dialog-title.svelte";
+import Footer from "./dialog-footer.svelte";
+import Header from "./dialog-header.svelte";
+import Overlay from "./dialog-overlay.svelte";
+import Content from "./dialog-content.svelte";
+import Description from "./dialog-description.svelte";
+
+const Root = DialogPrimitive.Root;
+const Trigger = DialogPrimitive.Trigger;
+const Close = DialogPrimitive.Close;
+const Portal = DialogPrimitive.Portal;
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose,
+};
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 00000000..6f784a14
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {#snippet children({ checked, indeterminate })}
+
+ {#if indeterminate}
+
+ {:else}
+
+ {/if}
+
+ {@render childrenProp?.()}
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-content.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 00000000..9bd4ebd6
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
new file mode 100644
index 00000000..bc865a31
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-item.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 00000000..cd18418e
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-label.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 00000000..131abe6c
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 00000000..1a5f50bc
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 00000000..53f1cb89
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 00000000..ed8ebadc
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 00000000..a8d932fa
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 00000000..b5044d7d
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,28 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/dropdown-menu/index.ts b/webapp/src/modules/components/ui/dropdown-menu/index.ts
new file mode 100644
index 00000000..40c45027
--- /dev/null
+++ b/webapp/src/modules/components/ui/dropdown-menu/index.ts
@@ -0,0 +1,50 @@
+import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
+import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
+import Content from "./dropdown-menu-content.svelte";
+import GroupHeading from "./dropdown-menu-group-heading.svelte";
+import Item from "./dropdown-menu-item.svelte";
+import Label from "./dropdown-menu-label.svelte";
+import RadioItem from "./dropdown-menu-radio-item.svelte";
+import Separator from "./dropdown-menu-separator.svelte";
+import Shortcut from "./dropdown-menu-shortcut.svelte";
+import SubContent from "./dropdown-menu-sub-content.svelte";
+import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
+
+const Sub = DropdownMenuPrimitive.Sub;
+const Root = DropdownMenuPrimitive.Root;
+const Trigger = DropdownMenuPrimitive.Trigger;
+const Group = DropdownMenuPrimitive.Group;
+const RadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+export {
+ CheckboxItem,
+ Content,
+ Root as DropdownMenu,
+ CheckboxItem as DropdownMenuCheckboxItem,
+ Content as DropdownMenuContent,
+ Group as DropdownMenuGroup,
+ GroupHeading as DropdownMenuGroupHeading,
+ Item as DropdownMenuItem,
+ Label as DropdownMenuLabel,
+ RadioGroup as DropdownMenuRadioGroup,
+ RadioItem as DropdownMenuRadioItem,
+ Separator as DropdownMenuSeparator,
+ Shortcut as DropdownMenuShortcut,
+ Sub as DropdownMenuSub,
+ SubContent as DropdownMenuSubContent,
+ SubTrigger as DropdownMenuSubTrigger,
+ Trigger as DropdownMenuTrigger,
+ Group,
+ GroupHeading,
+ Item,
+ Label,
+ RadioGroup,
+ RadioItem,
+ Root,
+ Separator,
+ Shortcut,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+};
diff --git a/webapp/src/modules/components/ui/form/form-button.svelte b/webapp/src/modules/components/ui/form/form-button.svelte
new file mode 100644
index 00000000..a52b038f
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-button.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/form/form-description.svelte b/webapp/src/modules/components/ui/form/form-description.svelte
new file mode 100644
index 00000000..f4f7e2a0
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/form/form-element-field.svelte b/webapp/src/modules/components/ui/form/form-element-field.svelte
new file mode 100644
index 00000000..07a8f7d5
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-element-field.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+ {#snippet children({ constraints, errors, tainted, value })}
+
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
+
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/form/form-field-errors.svelte b/webapp/src/modules/components/ui/form/form-field-errors.svelte
new file mode 100644
index 00000000..942a8913
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-field-errors.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#snippet children({ errors, errorProps })}
+ {#if childrenProp}
+ {@render childrenProp({ errors, errorProps })}
+ {:else}
+ {#each errors as error}
+ {error}
+ {/each}
+ {/if}
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/form/form-field.svelte b/webapp/src/modules/components/ui/form/form-field.svelte
new file mode 100644
index 00000000..cdbec7b5
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-field.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+ {#snippet children({ constraints, errors, tainted, value })}
+
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
+
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/form/form-fieldset.svelte b/webapp/src/modules/components/ui/form/form-fieldset.svelte
new file mode 100644
index 00000000..cf38825e
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-fieldset.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/form/form-label.svelte b/webapp/src/modules/components/ui/form/form-label.svelte
new file mode 100644
index 00000000..4103add9
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-label.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {#snippet child({ props })}
+
+ {@render children?.()}
+
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/form/form-legend.svelte b/webapp/src/modules/components/ui/form/form-legend.svelte
new file mode 100644
index 00000000..04b7b323
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/form-legend.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/form/index.ts b/webapp/src/modules/components/ui/form/index.ts
new file mode 100644
index 00000000..0713927c
--- /dev/null
+++ b/webapp/src/modules/components/ui/form/index.ts
@@ -0,0 +1,33 @@
+import * as FormPrimitive from "formsnap";
+import Description from "./form-description.svelte";
+import Label from "./form-label.svelte";
+import FieldErrors from "./form-field-errors.svelte";
+import Field from "./form-field.svelte";
+import Fieldset from "./form-fieldset.svelte";
+import Legend from "./form-legend.svelte";
+import ElementField from "./form-element-field.svelte";
+import Button from "./form-button.svelte";
+
+const Control = FormPrimitive.Control;
+
+export {
+ Field,
+ Control,
+ Label,
+ Button,
+ FieldErrors,
+ Description,
+ Fieldset,
+ Legend,
+ ElementField,
+ //
+ Field as FormField,
+ Control as FormControl,
+ Description as FormDescription,
+ Label as FormLabel,
+ FieldErrors as FormFieldErrors,
+ Fieldset as FormFieldset,
+ Legend as FormLegend,
+ ElementField as FormElementField,
+ Button as FormButton,
+};
diff --git a/webapp/src/modules/components/ui/hooks/is-mobile.svelte.ts b/webapp/src/modules/components/ui/hooks/is-mobile.svelte.ts
new file mode 100644
index 00000000..87bea4b1
--- /dev/null
+++ b/webapp/src/modules/components/ui/hooks/is-mobile.svelte.ts
@@ -0,0 +1,27 @@
+import { untrack } from "svelte";
+
+const MOBILE_BREAKPOINT = 768;
+
+export class IsMobile {
+ #current = $state(false);
+
+ constructor() {
+ $effect(() => {
+ return untrack(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ this.#current = window.innerWidth < MOBILE_BREAKPOINT;
+ };
+ mql.addEventListener("change", onChange);
+ onChange();
+ return () => {
+ mql.removeEventListener("change", onChange);
+ };
+ });
+ });
+ }
+
+ get current() {
+ return this.#current;
+ }
+}
diff --git a/webapp/src/modules/components/ui/input/index.ts b/webapp/src/modules/components/ui/input/index.ts
new file mode 100644
index 00000000..f47b6d3f
--- /dev/null
+++ b/webapp/src/modules/components/ui/input/index.ts
@@ -0,0 +1,7 @@
+import Root from "./input.svelte";
+
+export {
+ Root,
+ //
+ Root as Input,
+};
diff --git a/webapp/src/modules/components/ui/input/input.svelte b/webapp/src/modules/components/ui/input/input.svelte
new file mode 100644
index 00000000..98ce00c0
--- /dev/null
+++ b/webapp/src/modules/components/ui/input/input.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/label/index.ts b/webapp/src/modules/components/ui/label/index.ts
new file mode 100644
index 00000000..8bfca0b3
--- /dev/null
+++ b/webapp/src/modules/components/ui/label/index.ts
@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/webapp/src/modules/components/ui/label/label.svelte b/webapp/src/modules/components/ui/label/label.svelte
new file mode 100644
index 00000000..0ee94d18
--- /dev/null
+++ b/webapp/src/modules/components/ui/label/label.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/pagination/index.ts b/webapp/src/modules/components/ui/pagination/index.ts
new file mode 100644
index 00000000..d83c7a94
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/index.ts
@@ -0,0 +1,25 @@
+import Root from "./pagination.svelte";
+import Content from "./pagination-content.svelte";
+import Item from "./pagination-item.svelte";
+import Link from "./pagination-link.svelte";
+import PrevButton from "./pagination-prev-button.svelte";
+import NextButton from "./pagination-next-button.svelte";
+import Ellipsis from "./pagination-ellipsis.svelte";
+
+export {
+ Root,
+ Content,
+ Item,
+ Link,
+ PrevButton,
+ NextButton,
+ Ellipsis,
+ //
+ Root as Pagination,
+ Content as PaginationContent,
+ Item as PaginationItem,
+ Link as PaginationLink,
+ PrevButton as PaginationPrevButton,
+ NextButton as PaginationNextButton,
+ Ellipsis as PaginationEllipsis,
+};
diff --git a/webapp/src/modules/components/ui/pagination/pagination-content.svelte b/webapp/src/modules/components/ui/pagination/pagination-content.svelte
new file mode 100644
index 00000000..34f82b01
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-content.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination-ellipsis.svelte b/webapp/src/modules/components/ui/pagination/pagination-ellipsis.svelte
new file mode 100644
index 00000000..ac97af45
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-ellipsis.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ More pages
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination-item.svelte b/webapp/src/modules/components/ui/pagination/pagination-item.svelte
new file mode 100644
index 00000000..09c10765
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-item.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination-link.svelte b/webapp/src/modules/components/ui/pagination/pagination-link.svelte
new file mode 100644
index 00000000..0ce52d31
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-link.svelte
@@ -0,0 +1,36 @@
+
+
+{#snippet Fallback()}
+ {page.value}
+{/snippet}
+
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination-next-button.svelte b/webapp/src/modules/components/ui/pagination/pagination-next-button.svelte
new file mode 100644
index 00000000..a2ac6846
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-next-button.svelte
@@ -0,0 +1,31 @@
+
+
+{#snippet Fallback()}
+ Next
+
+{/snippet}
+
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination-prev-button.svelte b/webapp/src/modules/components/ui/pagination/pagination-prev-button.svelte
new file mode 100644
index 00000000..92f713ad
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination-prev-button.svelte
@@ -0,0 +1,31 @@
+
+
+{#snippet Fallback()}
+
+ Previous
+{/snippet}
+
+
diff --git a/webapp/src/modules/components/ui/pagination/pagination.svelte b/webapp/src/modules/components/ui/pagination/pagination.svelte
new file mode 100644
index 00000000..2982ad5e
--- /dev/null
+++ b/webapp/src/modules/components/ui/pagination/pagination.svelte
@@ -0,0 +1,25 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/popover/index.ts b/webapp/src/modules/components/ui/popover/index.ts
new file mode 100644
index 00000000..63aecf9a
--- /dev/null
+++ b/webapp/src/modules/components/ui/popover/index.ts
@@ -0,0 +1,17 @@
+import { Popover as PopoverPrimitive } from "bits-ui";
+import Content from "./popover-content.svelte";
+const Root = PopoverPrimitive.Root;
+const Trigger = PopoverPrimitive.Trigger;
+const Close = PopoverPrimitive.Close;
+
+export {
+ Root,
+ Content,
+ Trigger,
+ Close,
+ //
+ Root as Popover,
+ Content as PopoverContent,
+ Trigger as PopoverTrigger,
+ Close as PopoverClose,
+};
diff --git a/webapp/src/modules/components/ui/popover/popover-content.svelte b/webapp/src/modules/components/ui/popover/popover-content.svelte
new file mode 100644
index 00000000..630520fe
--- /dev/null
+++ b/webapp/src/modules/components/ui/popover/popover-content.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/resizable/index.ts b/webapp/src/modules/components/ui/resizable/index.ts
new file mode 100644
index 00000000..2e37f114
--- /dev/null
+++ b/webapp/src/modules/components/ui/resizable/index.ts
@@ -0,0 +1,13 @@
+import { Pane } from "paneforge";
+import Handle from "./resizable-handle.svelte";
+import PaneGroup from "./resizable-pane-group.svelte";
+
+export {
+ PaneGroup,
+ Pane,
+ Handle,
+ //
+ PaneGroup as ResizablePaneGroup,
+ Pane as ResizablePane,
+ Handle as ResizableHandle,
+};
diff --git a/webapp/src/modules/components/ui/resizable/resizable-handle.svelte b/webapp/src/modules/components/ui/resizable/resizable-handle.svelte
new file mode 100644
index 00000000..357c7399
--- /dev/null
+++ b/webapp/src/modules/components/ui/resizable/resizable-handle.svelte
@@ -0,0 +1,30 @@
+
+
+div]:rotate-90",
+ className
+ )}
+ {...restProps}
+>
+ {#if withHandle}
+
+
+
+ {/if}
+
diff --git a/webapp/src/modules/components/ui/resizable/resizable-pane-group.svelte b/webapp/src/modules/components/ui/resizable/resizable-pane-group.svelte
new file mode 100644
index 00000000..cbbf30d2
--- /dev/null
+++ b/webapp/src/modules/components/ui/resizable/resizable-pane-group.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/scroll-area/index.ts b/webapp/src/modules/components/ui/scroll-area/index.ts
new file mode 100644
index 00000000..e86a25b2
--- /dev/null
+++ b/webapp/src/modules/components/ui/scroll-area/index.ts
@@ -0,0 +1,10 @@
+import Scrollbar from "./scroll-area-scrollbar.svelte";
+import Root from "./scroll-area.svelte";
+
+export {
+ Root,
+ Scrollbar,
+ //,
+ Root as ScrollArea,
+ Scrollbar as ScrollAreaScrollbar,
+};
diff --git a/webapp/src/modules/components/ui/scroll-area/scroll-area-scrollbar.svelte b/webapp/src/modules/components/ui/scroll-area/scroll-area-scrollbar.svelte
new file mode 100644
index 00000000..22da23fc
--- /dev/null
+++ b/webapp/src/modules/components/ui/scroll-area/scroll-area-scrollbar.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/scroll-area/scroll-area.svelte b/webapp/src/modules/components/ui/scroll-area/scroll-area.svelte
new file mode 100644
index 00000000..d0b5081f
--- /dev/null
+++ b/webapp/src/modules/components/ui/scroll-area/scroll-area.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+ {@render children?.()}
+
+ {#if orientation === "vertical" || orientation === "both"}
+
+ {/if}
+ {#if orientation === "horizontal" || orientation === "both"}
+
+ {/if}
+
+
diff --git a/webapp/src/modules/components/ui/select/index.ts b/webapp/src/modules/components/ui/select/index.ts
new file mode 100644
index 00000000..f31b8aed
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/index.ts
@@ -0,0 +1,34 @@
+import { Select as SelectPrimitive } from "bits-ui";
+
+import GroupHeading from "./select-group-heading.svelte";
+import Item from "./select-item.svelte";
+import Content from "./select-content.svelte";
+import Trigger from "./select-trigger.svelte";
+import Separator from "./select-separator.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+
+const Root = SelectPrimitive.Root;
+const Group = SelectPrimitive.Group;
+
+export {
+ Root,
+ Group,
+ GroupHeading,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ GroupHeading as SelectGroupHeading,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+};
diff --git a/webapp/src/modules/components/ui/select/select-content.svelte b/webapp/src/modules/components/ui/select/select-content.svelte
new file mode 100644
index 00000000..08009d73
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-content.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/webapp/src/modules/components/ui/select/select-group-heading.svelte b/webapp/src/modules/components/ui/select/select-group-heading.svelte
new file mode 100644
index 00000000..5cee3522
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/select/select-item.svelte b/webapp/src/modules/components/ui/select/select-item.svelte
new file mode 100644
index 00000000..6223a8b0
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-item.svelte
@@ -0,0 +1,37 @@
+
+
+
+ {#snippet children({ selected, highlighted })}
+
+ {#if selected}
+
+ {/if}
+
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+
diff --git a/webapp/src/modules/components/ui/select/select-scroll-down-button.svelte b/webapp/src/modules/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 00000000..1fe485a0
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/select/select-scroll-up-button.svelte b/webapp/src/modules/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 00000000..6a98ffdf
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/select/select-separator.svelte b/webapp/src/modules/components/ui/select/select-separator.svelte
new file mode 100644
index 00000000..9bbbf433
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-separator.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/select/select-trigger.svelte b/webapp/src/modules/components/ui/select/select-trigger.svelte
new file mode 100644
index 00000000..3fb0c233
--- /dev/null
+++ b/webapp/src/modules/components/ui/select/select-trigger.svelte
@@ -0,0 +1,24 @@
+
+
+span]:line-clamp-1",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/separator/index.ts b/webapp/src/modules/components/ui/separator/index.ts
new file mode 100644
index 00000000..82442d2c
--- /dev/null
+++ b/webapp/src/modules/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/webapp/src/modules/components/ui/separator/separator.svelte b/webapp/src/modules/components/ui/separator/separator.svelte
new file mode 100644
index 00000000..7e68e0f4
--- /dev/null
+++ b/webapp/src/modules/components/ui/separator/separator.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sheet/index.ts b/webapp/src/modules/components/ui/sheet/index.ts
new file mode 100644
index 00000000..1cf1af1d
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/index.ts
@@ -0,0 +1,36 @@
+import { Dialog as SheetPrimitive } from "bits-ui";
+import Overlay from "./sheet-overlay.svelte";
+import Content from "./sheet-content.svelte";
+import Header from "./sheet-header.svelte";
+import Footer from "./sheet-footer.svelte";
+import Title from "./sheet-title.svelte";
+import Description from "./sheet-description.svelte";
+
+const Root = SheetPrimitive.Root;
+const Close = SheetPrimitive.Close;
+const Trigger = SheetPrimitive.Trigger;
+const Portal = SheetPrimitive.Portal;
+
+export {
+ Root,
+ Close,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Header,
+ Footer,
+ Title,
+ Description,
+ //
+ Root as Sheet,
+ Close as SheetClose,
+ Trigger as SheetTrigger,
+ Portal as SheetPortal,
+ Overlay as SheetOverlay,
+ Content as SheetContent,
+ Header as SheetHeader,
+ Footer as SheetFooter,
+ Title as SheetTitle,
+ Description as SheetDescription,
+};
diff --git a/webapp/src/modules/components/ui/sheet/sheet-content.svelte b/webapp/src/modules/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 00000000..19a494ba
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/webapp/src/modules/components/ui/sheet/sheet-description.svelte b/webapp/src/modules/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 00000000..1fd0c071
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sheet/sheet-footer.svelte b/webapp/src/modules/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 00000000..aefd43dd
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sheet/sheet-header.svelte b/webapp/src/modules/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 00000000..ed516818
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sheet/sheet-overlay.svelte b/webapp/src/modules/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 00000000..9e51de96
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sheet/sheet-title.svelte b/webapp/src/modules/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 00000000..17333850
--- /dev/null
+++ b/webapp/src/modules/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sidebar/constants.ts b/webapp/src/modules/components/ui/sidebar/constants.ts
new file mode 100644
index 00000000..4de44351
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/constants.ts
@@ -0,0 +1,6 @@
+export const SIDEBAR_COOKIE_NAME = "sidebar:state";
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = "16rem";
+export const SIDEBAR_WIDTH_MOBILE = "18rem";
+export const SIDEBAR_WIDTH_ICON = "3rem";
+export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
diff --git a/webapp/src/modules/components/ui/sidebar/context.svelte.ts b/webapp/src/modules/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 00000000..e472397c
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/context.svelte.ts
@@ -0,0 +1,81 @@
+import { IsMobile } from "@/components/ui/hooks/is-mobile.svelte.js";
+import { getContext, setContext } from "svelte";
+import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
+
+type Getter = () => T;
+
+export type SidebarStateProps = {
+ /**
+ * A getter function that returns the current open state of the sidebar.
+ * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
+ * component.
+ */
+ open: Getter;
+
+ /**
+ * A function that sets the open state of the sidebar. To support `bind:open`, we need
+ * a source of truth for changing the open state to ensure it will be synced throughout
+ * the sub-components and any `bind:` references.
+ */
+ setOpen: (open: boolean) => void;
+};
+
+class SidebarState {
+ readonly props: SidebarStateProps;
+ open = $derived.by(() => this.props.open());
+ openMobile = $state(false);
+ setOpen: SidebarStateProps["setOpen"];
+ #isMobile: IsMobile;
+ state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
+
+ constructor(props: SidebarStateProps) {
+ this.setOpen = props.setOpen;
+ this.#isMobile = new IsMobile();
+ this.props = props;
+ }
+
+ // Convenience getter for checking if the sidebar is mobile
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
+ get isMobile() {
+ return this.#isMobile.current;
+ }
+
+ // Event handler to apply to the ``
+ handleShortcutKeydown = (e: KeyboardEvent) => {
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ this.toggle();
+ }
+ };
+
+ setOpenMobile = (value: boolean) => {
+ this.openMobile = value;
+ };
+
+ toggle = () => {
+ return this.#isMobile.current
+ ? (this.openMobile = !this.openMobile)
+ : this.setOpen(!this.open);
+ };
+}
+
+const SYMBOL_KEY = "scn-sidebar";
+
+/**
+ * Instantiates a new `SidebarState` instance and sets it in the context.
+ *
+ * @param props The constructor props for the `SidebarState` class.
+ * @returns The `SidebarState` instance.
+ */
+export function setSidebar(props: SidebarStateProps): SidebarState {
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
+}
+
+/**
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
+ * so you cannot destructure it.
+ * @returns The `SidebarState` instance.
+ */
+export function useSidebar(): SidebarState {
+ return getContext(Symbol.for(SYMBOL_KEY));
+}
diff --git a/webapp/src/modules/components/ui/sidebar/index.ts b/webapp/src/modules/components/ui/sidebar/index.ts
new file mode 100644
index 00000000..318a3417
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/index.ts
@@ -0,0 +1,75 @@
+import { useSidebar } from "./context.svelte.js";
+import Content from "./sidebar-content.svelte";
+import Footer from "./sidebar-footer.svelte";
+import GroupAction from "./sidebar-group-action.svelte";
+import GroupContent from "./sidebar-group-content.svelte";
+import GroupLabel from "./sidebar-group-label.svelte";
+import Group from "./sidebar-group.svelte";
+import Header from "./sidebar-header.svelte";
+import Input from "./sidebar-input.svelte";
+import Inset from "./sidebar-inset.svelte";
+import MenuAction from "./sidebar-menu-action.svelte";
+import MenuBadge from "./sidebar-menu-badge.svelte";
+import MenuButton from "./sidebar-menu-button.svelte";
+import MenuItem from "./sidebar-menu-item.svelte";
+import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
+import MenuSubButton from "./sidebar-menu-sub-button.svelte";
+import MenuSubItem from "./sidebar-menu-sub-item.svelte";
+import MenuSub from "./sidebar-menu-sub.svelte";
+import Menu from "./sidebar-menu.svelte";
+import Provider from "./sidebar-provider.svelte";
+import Rail from "./sidebar-rail.svelte";
+import Separator from "./sidebar-separator.svelte";
+import Trigger from "./sidebar-trigger.svelte";
+import Root from "./sidebar.svelte";
+
+export {
+ Content,
+ Footer,
+ Group,
+ GroupAction,
+ GroupContent,
+ GroupLabel,
+ Header,
+ Input,
+ Inset,
+ Menu,
+ MenuAction,
+ MenuBadge,
+ MenuButton,
+ MenuItem,
+ MenuSkeleton,
+ MenuSub,
+ MenuSubButton,
+ MenuSubItem,
+ Provider,
+ Rail,
+ Root,
+ Separator,
+ //
+ Root as Sidebar,
+ Content as SidebarContent,
+ Footer as SidebarFooter,
+ Group as SidebarGroup,
+ GroupAction as SidebarGroupAction,
+ GroupContent as SidebarGroupContent,
+ GroupLabel as SidebarGroupLabel,
+ Header as SidebarHeader,
+ Input as SidebarInput,
+ Inset as SidebarInset,
+ Menu as SidebarMenu,
+ MenuAction as SidebarMenuAction,
+ MenuBadge as SidebarMenuBadge,
+ MenuButton as SidebarMenuButton,
+ MenuItem as SidebarMenuItem,
+ MenuSkeleton as SidebarMenuSkeleton,
+ MenuSub as SidebarMenuSub,
+ MenuSubButton as SidebarMenuSubButton,
+ MenuSubItem as SidebarMenuSubItem,
+ Provider as SidebarProvider,
+ Rail as SidebarRail,
+ Separator as SidebarSeparator,
+ Trigger as SidebarTrigger,
+ Trigger,
+ useSidebar,
+};
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-content.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 00000000..0b4736b7
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-footer.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 00000000..98eb67dd
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-group-action.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 00000000..69121477
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
+
+
+{#if child}
+ {@render child({ props: propObj })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-group-content.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 00000000..84aed720
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-group-label.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 00000000..26e214d3
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-group.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 00000000..0080908c
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-header.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 00000000..0206fb39
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-input.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 00000000..b880b9b1
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-inset.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 00000000..ab1ab19e
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-action.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 00000000..0ea66e8d
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-badge.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 00000000..94acb44d
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-button.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 00000000..237da630
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,97 @@
+
+
+
+
+{#snippet Button({ props }: { props?: Record })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+
+
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+
+
+
+{/if}
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-item.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 00000000..60c2e362
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-skeleton.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 00000000..ac1e56e8
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if showIcon}
+
+ {/if}
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-button.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 00000000..f8d9ff75
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-item.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 00000000..6e7346d3
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 00000000..e1d8ebb9
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-menu.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 00000000..f8335f0b
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-provider.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 00000000..31abc2c5
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-rail.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 00000000..e867f7cc
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
+
+
+ sidebar.toggle()}
+ title="Toggle Sidebar"
+ class={cn(
+ "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
+ "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
+ "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
+ "group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-separator.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 00000000..b6e157a9
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar-trigger.svelte b/webapp/src/modules/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 00000000..6caacd44
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,34 @@
+
+
+ {
+ onclick?.(e);
+ sidebar.toggle();
+ }}
+ data-sidebar="trigger"
+ variant="ghost"
+ size="icon"
+ class={cn("h-7 w-7", className)}
+ {...restProps}
+>
+
+ Toggle Sidebar
+
diff --git a/webapp/src/modules/components/ui/sidebar/sidebar.svelte b/webapp/src/modules/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 00000000..cf9f0e17
--- /dev/null
+++ b/webapp/src/modules/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,98 @@
+
+
+{#if collapsible === "none"}
+
+ {@render children?.()}
+
+{:else if sidebar.isMobile}
+
+
+
+{:else}
+
+{/if}
diff --git a/webapp/src/modules/components/ui/skeleton/index.ts b/webapp/src/modules/components/ui/skeleton/index.ts
new file mode 100644
index 00000000..186db219
--- /dev/null
+++ b/webapp/src/modules/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
+import Root from "./skeleton.svelte";
+
+export {
+ Root,
+ //
+ Root as Skeleton,
+};
diff --git a/webapp/src/modules/components/ui/skeleton/skeleton.svelte b/webapp/src/modules/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 00000000..593f803f
--- /dev/null
+++ b/webapp/src/modules/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/sonner/index.ts b/webapp/src/modules/components/ui/sonner/index.ts
new file mode 100644
index 00000000..1ad9f4a2
--- /dev/null
+++ b/webapp/src/modules/components/ui/sonner/index.ts
@@ -0,0 +1 @@
+export { default as Toaster } from "./sonner.svelte";
diff --git a/webapp/src/modules/components/ui/sonner/sonner.svelte b/webapp/src/modules/components/ui/sonner/sonner.svelte
new file mode 100644
index 00000000..8050e049
--- /dev/null
+++ b/webapp/src/modules/components/ui/sonner/sonner.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/switch/index.ts b/webapp/src/modules/components/ui/switch/index.ts
new file mode 100644
index 00000000..f5533db7
--- /dev/null
+++ b/webapp/src/modules/components/ui/switch/index.ts
@@ -0,0 +1,7 @@
+import Root from "./switch.svelte";
+
+export {
+ Root,
+ //
+ Root as Switch,
+};
diff --git a/webapp/src/modules/components/ui/switch/switch.svelte b/webapp/src/modules/components/ui/switch/switch.svelte
new file mode 100644
index 00000000..63ff82f8
--- /dev/null
+++ b/webapp/src/modules/components/ui/switch/switch.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/webapp/src/modules/components/ui/table/index.ts b/webapp/src/modules/components/ui/table/index.ts
new file mode 100644
index 00000000..14695c81
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Root from "./table.svelte";
+import Body from "./table-body.svelte";
+import Caption from "./table-caption.svelte";
+import Cell from "./table-cell.svelte";
+import Footer from "./table-footer.svelte";
+import Head from "./table-head.svelte";
+import Header from "./table-header.svelte";
+import Row from "./table-row.svelte";
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow,
+};
diff --git a/webapp/src/modules/components/ui/table/table-body.svelte b/webapp/src/modules/components/ui/table/table-body.svelte
new file mode 100644
index 00000000..0d5127f2
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-body.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-caption.svelte b/webapp/src/modules/components/ui/table/table-caption.svelte
new file mode 100644
index 00000000..2e2a442a
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-caption.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-cell.svelte b/webapp/src/modules/components/ui/table/table-cell.svelte
new file mode 100644
index 00000000..bda4975c
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-cell.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-footer.svelte b/webapp/src/modules/components/ui/table/table-footer.svelte
new file mode 100644
index 00000000..49f5c25e
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-footer.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-head.svelte b/webapp/src/modules/components/ui/table/table-head.svelte
new file mode 100644
index 00000000..8286e0dc
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-header.svelte b/webapp/src/modules/components/ui/table/table-header.svelte
new file mode 100644
index 00000000..953288a4
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-header.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table-row.svelte b/webapp/src/modules/components/ui/table/table-row.svelte
new file mode 100644
index 00000000..41290adb
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/components/ui/table/table.svelte b/webapp/src/modules/components/ui/table/table.svelte
new file mode 100644
index 00000000..2cbea8d7
--- /dev/null
+++ b/webapp/src/modules/components/ui/table/table.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/webapp/src/modules/components/ui/textarea/index.ts b/webapp/src/modules/components/ui/textarea/index.ts
new file mode 100644
index 00000000..6eb6ba34
--- /dev/null
+++ b/webapp/src/modules/components/ui/textarea/index.ts
@@ -0,0 +1,28 @@
+import Root from "./textarea.svelte";
+
+type FormTextareaEvent = T & {
+ currentTarget: EventTarget & HTMLTextAreaElement;
+};
+
+type TextareaEvents = {
+ blur: FormTextareaEvent;
+ change: FormTextareaEvent;
+ click: FormTextareaEvent;
+ focus: FormTextareaEvent;
+ keydown: FormTextareaEvent;
+ keypress: FormTextareaEvent;
+ keyup: FormTextareaEvent;
+ mouseover: FormTextareaEvent;
+ mouseenter: FormTextareaEvent;
+ mouseleave: FormTextareaEvent;
+ paste: FormTextareaEvent;
+ input: FormTextareaEvent;
+};
+
+export {
+ Root,
+ //
+ Root as Textarea,
+ type TextareaEvents,
+ type FormTextareaEvent,
+};
diff --git a/webapp/src/modules/components/ui/textarea/textarea.svelte b/webapp/src/modules/components/ui/textarea/textarea.svelte
new file mode 100644
index 00000000..6ad157f1
--- /dev/null
+++ b/webapp/src/modules/components/ui/textarea/textarea.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/tooltip/index.ts b/webapp/src/modules/components/ui/tooltip/index.ts
new file mode 100644
index 00000000..e9e1fd73
--- /dev/null
+++ b/webapp/src/modules/components/ui/tooltip/index.ts
@@ -0,0 +1,18 @@
+import { Tooltip as TooltipPrimitive } from "bits-ui";
+import Content from "./tooltip-content.svelte";
+
+const Root = TooltipPrimitive.Root;
+const Trigger = TooltipPrimitive.Trigger;
+const Provider = TooltipPrimitive.Provider;
+
+export {
+ Root,
+ Trigger,
+ Content,
+ Provider,
+ //
+ Root as Tooltip,
+ Content as TooltipContent,
+ Trigger as TooltipTrigger,
+ Provider as TooltipProvider,
+};
diff --git a/webapp/src/modules/components/ui/tooltip/tooltip-content.svelte b/webapp/src/modules/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 00000000..9cc1ab2a
--- /dev/null
+++ b/webapp/src/modules/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/webapp/src/modules/components/ui/utils.ts b/webapp/src/modules/components/ui/utils.ts
new file mode 100644
index 00000000..ac680b30
--- /dev/null
+++ b/webapp/src/modules/components/ui/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/webapp/src/modules/did/DIDButton.svelte b/webapp/src/modules/did/DIDButton.svelte
new file mode 100644
index 00000000..7330782e
--- /dev/null
+++ b/webapp/src/modules/did/DIDButton.svelte
@@ -0,0 +1,18 @@
+
+
+{#if $featureFlags.DID && url}
+
+ {m.my_DID()}
+
+{/if}
diff --git a/webapp/src/lib/did/index.ts b/webapp/src/modules/did/index.ts
similarity index 68%
rename from webapp/src/lib/did/index.ts
rename to webapp/src/modules/did/index.ts
index 5a2042ee..b15a8777 100644
--- a/webapp/src/lib/did/index.ts
+++ b/webapp/src/modules/did/index.ts
@@ -1,8 +1,4 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { getUserPublicKeys } from '$lib/keypairoom/utils';
+import { getUserPublicKeys } from '@/keypairoom/utils';
export async function getUserDidUrl(userId: string | undefined = undefined) {
const publicKeys = await getUserPublicKeys(userId);
diff --git a/webapp/src/modules/features/generate.features-list.ts b/webapp/src/modules/features/generate.features-list.ts
new file mode 100644
index 00000000..193dfbf5
--- /dev/null
+++ b/webapp/src/modules/features/generate.features-list.ts
@@ -0,0 +1,25 @@
+import fs from 'fs';
+import path from 'node:path';
+import { formatCode, GENERATED, initAdminPocketbase, logCodegenResult } from '@/utils/codegen';
+
+//
+
+const TYPE_NAME = 'Feature';
+const OBJECT_NAME = `${TYPE_NAME}s`;
+
+const pb = await initAdminPocketbase();
+const featuresRecords = await pb.collection('features').getFullList();
+const featuresEntries = featuresRecords.map((f) => `${f.name.toUpperCase()}: '${f.name}'`);
+
+const code = `
+export const ${OBJECT_NAME} = {
+ ${featuresEntries.join(',\n')}
+} as const
+
+export type ${TYPE_NAME} = typeof ${OBJECT_NAME} [keyof typeof ${OBJECT_NAME}];
+`;
+
+const formattedCode = await formatCode(code);
+const filePath = path.join(import.meta.dirname, `features-list.${GENERATED}.ts`);
+fs.writeFileSync(filePath, formattedCode);
+logCodegenResult('features list', filePath);
diff --git a/webapp/src/lib/features/index.ts b/webapp/src/modules/features/index.ts
similarity index 73%
rename from webapp/src/lib/features/index.ts
rename to webapp/src/modules/features/index.ts
index 788694be..4bda7fe4 100644
--- a/webapp/src/lib/features/index.ts
+++ b/webapp/src/modules/features/index.ts
@@ -1,10 +1,6 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { Features } from './list';
-import { pb } from '$lib/pocketbase';
-import { Collections, type FeaturesResponse } from '$lib/pocketbase/types';
+import { Features } from './features-list.generated';
+import { pb } from '@/pocketbase';
+import { Collections, type FeaturesResponse } from '@/pocketbase/types';
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
@@ -31,4 +27,4 @@ export async function loadFeatureFlags(fetchFn = fetch): Promise {
return flags as FeatureFlags;
}
-export * from './list';
+export * from './features-list.generated';
diff --git a/webapp/src/modules/forms/components/formDebug.svelte b/webapp/src/modules/forms/components/formDebug.svelte
new file mode 100644
index 00000000..010c1899
--- /dev/null
+++ b/webapp/src/modules/forms/components/formDebug.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/webapp/src/modules/forms/components/formError.svelte b/webapp/src/modules/forms/components/formError.svelte
new file mode 100644
index 00000000..2a076cc5
--- /dev/null
+++ b/webapp/src/modules/forms/components/formError.svelte
@@ -0,0 +1,28 @@
+
+
+{#if error}
+
+ {#snippet content({ Title, Description })}
+ {m.Error()}
+
+ {#if error.messages.length > 0}
+ {#each error.messages as message}
+ {message}
+ {/each}
+ {/if}
+
+ {/snippet}
+
+{/if}
diff --git a/webapp/src/modules/forms/components/requiredIndicator.svelte b/webapp/src/modules/forms/components/requiredIndicator.svelte
new file mode 100644
index 00000000..c0dd366a
--- /dev/null
+++ b/webapp/src/modules/forms/components/requiredIndicator.svelte
@@ -0,0 +1,19 @@
+
+
+{#if !hideRequiredIndicator && isFieldRequired}
+ *
+{/if}
diff --git a/webapp/src/modules/forms/components/submitButton.svelte b/webapp/src/modules/forms/components/submitButton.svelte
new file mode 100644
index 00000000..700b9aa1
--- /dev/null
+++ b/webapp/src/modules/forms/components/submitButton.svelte
@@ -0,0 +1,18 @@
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/forms/fields/checkboxField.svelte b/webapp/src/modules/forms/fields/checkboxField.svelte
new file mode 100644
index 00000000..c371dc9a
--- /dev/null
+++ b/webapp/src/modules/forms/fields/checkboxField.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {#snippet children({ props })}
+
+ ($value = v)} />
+
+
+ {#if childrenSnippet}
+ {@render childrenSnippet()}
+ {:else}
+ {options.label ?? capitalize(name)}
+ {/if}
+
+
+
+ {/snippet}
+
+
+ {#if options.description}
+ {options.description}
+ {/if}
+
+
+
diff --git a/webapp/src/modules/forms/fields/dateField.svelte b/webapp/src/modules/forms/fields/dateField.svelte
new file mode 100644
index 00000000..cf873b4c
--- /dev/null
+++ b/webapp/src/modules/forms/fields/dateField.svelte
@@ -0,0 +1 @@
+
diff --git a/webapp/src/modules/forms/fields/field.svelte b/webapp/src/modules/forms/fields/field.svelte
new file mode 100644
index 00000000..56653f80
--- /dev/null
+++ b/webapp/src/modules/forms/fields/field.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+ {#snippet children({ props })}
+ {#if valueProxy}
+
+ {/if}
+ {/snippet}
+
+
diff --git a/webapp/src/modules/forms/fields/fileField.svelte b/webapp/src/modules/forms/fields/fileField.svelte
new file mode 100644
index 00000000..1a9c2b9b
--- /dev/null
+++ b/webapp/src/modules/forms/fields/fileField.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+ {#snippet children({ props })}
+
+ {#snippet children({ addFiles })}
+ {
+ const fileList = e.currentTarget.files;
+ if (fileList) addFiles([...fileList]);
+ e.currentTarget.value = '';
+ }}
+ />
+
+ {/snippet}
+
+ {/snippet}
+
+
diff --git a/webapp/src/modules/forms/fields/fileField.ts b/webapp/src/modules/forms/fields/fileField.ts
new file mode 100644
index 00000000..d63a1ed6
--- /dev/null
+++ b/webapp/src/modules/forms/fields/fileField.ts
@@ -0,0 +1,114 @@
+import { pipe, Tuple } from 'effect';
+import type { FORM_ERROR_PATH } from '../form';
+import type { FileManagerValidator, RejectedFile } from '@/components/ui-custom/fileManager.svelte';
+import type { GenericRecord, If, IsArray } from '@/utils/types';
+import type { SuperForm } from 'sveltekit-superforms';
+
+/* Files validation
+ *
+ * - Objective
+ * Here we are trying to validate files *before* they arrive into the form
+ *
+ * - Reason(s)
+ * If a single file is bad, it must not replace a good one
+ * If multiple files are uploaded, good ones must be saved and bad ones discarded
+ *
+ * - Restated objective
+ * We want to reject bad files and notify the user about it
+ */
+
+export function createFilesValidator>(
+ form: F,
+ field: string,
+ multiple: boolean
+): FileManagerValidator {
+ /**
+ * - Implementation
+ * To validate the file field, *before* actually adding the files in the form
+ * we can use the `validate` function from superforms, but in "preview mode"
+ * and use it as validator for `FileManager` component
+ */
+
+ const { validate } = form;
+
+ function previewValidate(data: T) {
+ // TODO: this should throw when "field" is not valid
+ return validate(field, {
+ value: data,
+ taint: false,
+ update: false
+ }) as Promise>;
+ }
+
+ /* Type fix
+ *
+ * Superforms `validate` function return type is not correct:
+ * - Function signature says it is `string[] | undefined`
+ * - If you validate an array value, it is actually `Record | undefined`
+ */
+
+ type ValidationResult =
+ | undefined
+ | If, Record, string[]>;
+
+ /* Files validator */
+
+ const validator: FileManagerValidator = async (newFiles) => {
+ if (!multiple) {
+ const newFile = newFiles[0];
+ const validationResult = await previewValidate(newFile);
+ const acceptedFiles: File[] = [];
+ const rejectedFiles: RejectedFile[] = [];
+ if (!validationResult) acceptedFiles.push(newFile);
+ else
+ rejectedFiles.push({
+ file: newFile,
+ reasons: validationResult
+ });
+ return {
+ acceptedFiles,
+ rejectedFiles
+ };
+ } else {
+ /**
+ * - Issue
+ * We cannot validate multiple files together with superforms [validate] function
+ * - Reason
+ * The validation result is a record where the keys are not linked to the files
+ * - To reproduce, run here:
+ * `console.log(await previewFieldValidation(name, newfiles))`
+ * and upload multiple files, some of which invalid
+ *
+ * - Solution
+ * We validate each file as if it is an array, then extract the result
+ * `previewFieldValidation(name, [f])`
+ */
+ const validationEntries = await pipe(
+ newFiles.map((f) =>
+ previewValidate([f])
+ .then((result) => (result ? result[0] : []))
+ .then((result) => Tuple.make(f, result))
+ ),
+ (entries) => Promise.all(entries)
+ );
+
+ const acceptedFiles: File[] = validationEntries
+ .filter(([, errors]) => errors.length == 0)
+ .map(([file]) => file);
+
+ const rejectedFiles: RejectedFile[] = validationEntries
+ .filter(([, errors]) => errors.length > 0)
+ .map(([file, errors]) => ({
+ file,
+ reasons: errors
+ }));
+
+ return {
+ acceptedFiles,
+ rejectedFiles
+ };
+ }
+ };
+
+ return validator;
+}
diff --git a/webapp/src/modules/forms/fields/index.ts b/webapp/src/modules/forms/fields/index.ts
new file mode 100644
index 00000000..82693695
--- /dev/null
+++ b/webapp/src/modules/forms/fields/index.ts
@@ -0,0 +1,9 @@
+import Field from './field.svelte';
+import FileField from './fileField.svelte';
+import SwitchField from './switchField.svelte';
+import CheckboxField from './checkboxField.svelte';
+import TextareaField from './textareaField.svelte';
+import SelectField from './selectField.svelte';
+import DateField from './dateField.svelte';
+
+export { Field, FileField, SwitchField, CheckboxField, TextareaField, SelectField, DateField };
diff --git a/webapp/src/modules/forms/fields/parts/fieldWrapper.svelte b/webapp/src/modules/forms/fields/parts/fieldWrapper.svelte
new file mode 100644
index 00000000..24f675ad
--- /dev/null
+++ b/webapp/src/modules/forms/fields/parts/fieldWrapper.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#snippet children({ props })}
+
+
+ {options.label ?? capitalize(field)}
+
+
+
+ {@render child?.({ props })}
+ {/snippet}
+
+
+{#if options.description}
+ {options.description}
+{/if}
+
+
diff --git a/webapp/src/modules/forms/fields/selectField.svelte b/webapp/src/modules/forms/fields/selectField.svelte
new file mode 100644
index 00000000..d1ad0f2b
--- /dev/null
+++ b/webapp/src/modules/forms/fields/selectField.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+ {#snippet children({ props })}
+ ($value = data)}
+ />
+ {/snippet}
+
+
diff --git a/webapp/src/modules/forms/fields/switchField.svelte b/webapp/src/modules/forms/fields/switchField.svelte
new file mode 100644
index 00000000..f75769db
--- /dev/null
+++ b/webapp/src/modules/forms/fields/switchField.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+ {#snippet children({ props })}
+
+ ($value = v)} />
+ {options.label ?? capitalize(name)}
+
+ {/snippet}
+
+
+ {#if options.description}
+ {options.description}
+ {/if}
+
+
+
diff --git a/webapp/src/modules/forms/fields/textareaField.svelte b/webapp/src/modules/forms/fields/textareaField.svelte
new file mode 100644
index 00000000..c4cfc19f
--- /dev/null
+++ b/webapp/src/modules/forms/fields/textareaField.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+ {#snippet children({ props })}
+
+ {/snippet}
+
+
diff --git a/webapp/src/modules/forms/fields/types.ts b/webapp/src/modules/forms/fields/types.ts
new file mode 100644
index 00000000..8412277d
--- /dev/null
+++ b/webapp/src/modules/forms/fields/types.ts
@@ -0,0 +1,4 @@
+export type FieldOptions = {
+ label: string;
+ description: string;
+};
diff --git a/webapp/src/modules/forms/form.svelte b/webapp/src/modules/forms/form.svelte
new file mode 100644
index 00000000..ccf5533d
--- /dev/null
+++ b/webapp/src/modules/forms/form.svelte
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+{#if loadingState}
+ {@render loadingState({ isLoading: $delayed })}
+{:else if !hide.includes('loading_state')}
+
+ {#if loadingStateContent}
+ {@render loadingStateContent()}
+ {:else}
+ {m.Please_wait()}
+ {/if}
+
+{/if}
diff --git a/webapp/src/modules/forms/form.ts b/webapp/src/modules/forms/form.ts
new file mode 100644
index 00000000..3c80db14
--- /dev/null
+++ b/webapp/src/modules/forms/form.ts
@@ -0,0 +1,46 @@
+import { getExceptionMessage } from '@/utils/errors';
+import type { GenericRecord } from '@/utils/types';
+import type { FormOptions as SuperformOptions } from 'sveltekit-superforms';
+import { type ValidationAdapter } from 'sveltekit-superforms/adapters';
+import { defaults, setError, superForm } from 'sveltekit-superforms/client';
+
+//
+
+export type SubmitFunction = NonNullable<
+ SuperformOptions['onUpdate']
+>;
+
+export type FormOptions = Omit<
+ SuperformOptions,
+ 'onUpdate'
+>;
+
+export type CreateFormProps = {
+ adapter: ValidationAdapter;
+ options?: FormOptions;
+ onSubmit?: SubmitFunction;
+ initialData?: Partial;
+};
+
+export function createForm(props: CreateFormProps) {
+ const { adapter, initialData = {} as Partial, options = {}, onSubmit = () => {} } = props;
+ const form = defaults(initialData, adapter);
+ return superForm(form, {
+ SPA: true,
+ applyAction: false,
+ scrollToError: 'smooth',
+ validators: adapter,
+ dataType: 'json',
+ taintedMessage: null,
+ onUpdate: async (event) => {
+ try {
+ if (event.form.valid) await onSubmit(event);
+ } catch (e) {
+ setError(event.form, getExceptionMessage(e));
+ }
+ },
+ ...options
+ });
+}
+
+export const FORM_ERROR_PATH = '_errors';
diff --git a/webapp/src/modules/forms/index.ts b/webapp/src/modules/forms/index.ts
new file mode 100644
index 00000000..87b24c48
--- /dev/null
+++ b/webapp/src/modules/forms/index.ts
@@ -0,0 +1,8 @@
+import Form, { getFormContext } from './form.svelte';
+import { createForm, type FormOptions } from './form';
+
+import SubmitButton from './components/submitButton.svelte';
+import FormError from './components/formError.svelte';
+import FormDebug from './components/formDebug.svelte';
+
+export { createForm, getFormContext, Form, SubmitButton, FormError, FormDebug, type FormOptions };
diff --git a/webapp/src/modules/i18n/baseLanguageSelect.svelte b/webapp/src/modules/i18n/baseLanguageSelect.svelte
new file mode 100644
index 00000000..70406bd1
--- /dev/null
+++ b/webapp/src/modules/i18n/baseLanguageSelect.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+{@render trigger({
+ icon: Languages,
+ text: m.Select_language(),
+ language: currentLanguage
+})}
+
+{@render languagesSnippet({ languages })}
diff --git a/webapp/src/modules/i18n/index.ts b/webapp/src/modules/i18n/index.ts
new file mode 100644
index 00000000..f32152fa
--- /dev/null
+++ b/webapp/src/modules/i18n/index.ts
@@ -0,0 +1,66 @@
+import { createI18n } from '@inlang/paraglide-sveltekit';
+import * as runtime from './paraglide/runtime.js';
+import * as m from './paraglide/messages.js';
+
+export const i18n = createI18n(runtime);
+export { m };
+export * from './paraglide/runtime.js';
+
+//
+
+import { goto as sveltekitGoto } from '$app/navigation';
+import { redirect as sveltekitRedirect, type Page } from '@sveltejs/kit';
+import { get } from 'svelte/store';
+import { page } from '$app/stores';
+import type { AvailableLanguageTag } from './paraglide/runtime.js';
+import { Record } from 'effect';
+
+//
+
+export function resolveRoute(route: string, url: URL) {
+ const baseRoute = i18n.route(route);
+ return i18n.resolveRoute(baseRoute, i18n.getLanguageFromUrl(url));
+}
+
+export function goto(route: string) {
+ return sveltekitGoto(resolveRoute(route, get(page).url));
+}
+
+export function redirect(route: string, fromUrl: URL, statusCode: RedirectStatusCode = 303) {
+ return sveltekitRedirect(statusCode, resolveRoute(route, fromUrl));
+}
+
+type RedirectStatusCode = Parameters['0'];
+
+//
+
+export const languagesDisplay: Record = {
+ en: { flag: '🇬🇧', name: 'English' },
+ it: { flag: '🇮🇹', name: 'Italiano' },
+ de: { flag: '🇩🇪', name: 'Deutsch' },
+ fr: { flag: '🇫🇷', name: 'Français' },
+ da: { flag: '🇩🇰', name: 'Dansk' },
+ 'pt-br': { flag: '🇧🇷', name: 'Português' }
+};
+
+export function getLanguagesData(page: Page): LanguageData[] {
+ const currentLang = i18n.getLanguageFromUrl(page.url);
+
+ return Record.keys(languagesDisplay).map((lang) => ({
+ tag: lang,
+ href: i18n.route(page.url.pathname),
+ hreflang: lang,
+ flag: languagesDisplay[lang].flag,
+ name: languagesDisplay[lang].name,
+ isCurrent: lang == currentLang
+ }));
+}
+
+export type LanguageData = {
+ tag: AvailableLanguageTag;
+ href: string;
+ hreflang: AvailableLanguageTag;
+ flag: string;
+ name: string;
+ isCurrent: boolean;
+};
diff --git a/webapp/src/modules/i18n/languageSelect.svelte b/webapp/src/modules/i18n/languageSelect.svelte
new file mode 100644
index 00000000..1fa7a872
--- /dev/null
+++ b/webapp/src/modules/i18n/languageSelect.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+ {#snippet trigger(data)}
+
+ {#snippet child({ props: triggerAttributes })}
+ {#if triggerSnippet}
+ {@render triggerSnippet({ ...data, triggerAttributes })}
+ {:else}
+ {@const { icon: LanguageIcon, text } = data}
+
+
+ {text}
+
+ {/if}
+ {/snippet}
+
+ {/snippet}
+
+ {#snippet languages({ languages })}
+
+ {#each languages as { href, hreflang, name, flag, isCurrent }}
+
+
+ {flag}
+
+
+ {name}
+
+
+ {/each}
+
+ {/snippet}
+
+
diff --git a/webapp/src/lib/i18n/removeUnusedStrings.mjs b/webapp/src/modules/i18n/remove-unused-strings.ts
similarity index 77%
rename from webapp/src/lib/i18n/removeUnusedStrings.mjs
rename to webapp/src/modules/i18n/remove-unused-strings.ts
index 46e7d2e7..8bf95d6b 100644
--- a/webapp/src/lib/i18n/removeUnusedStrings.mjs
+++ b/webapp/src/modules/i18n/remove-unused-strings.ts
@@ -1,20 +1,10 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// @ts-check
-
import fs from 'node:fs';
import path from 'node:path';
/**
* Helper function to recursively find all files in a directory
- * @param {string} dirPath
- * @param {string[]} [exclude=[]]
- * @param {string[]} arrayOfFiles
- * @returns
*/
-function getAllFilesInFolder(dirPath, exclude = [], arrayOfFiles = []) {
+function getAllFilesInFolder(dirPath: string, exclude: string[] = [], arrayOfFiles: string[] = []) {
const files = fs.readdirSync(dirPath);
files.forEach(function (file) {
@@ -32,11 +22,8 @@ function getAllFilesInFolder(dirPath, exclude = [], arrayOfFiles = []) {
/**
* Extract used keys from the files based on the "m." pattern
- * @param {string[]} files
- * @param {string[]} keys
- * @returns {string[]}
- */
-function getKeysInFiles(files, keys) {
+ * */
+function getKeysInFiles(files: string[], keys: string[]) {
const usedKeys = new Set();
files.forEach((file) => {
@@ -78,17 +65,15 @@ function filterJsonByKeys(json, validKeys) {
/**
* Main function to extract keys and filter the JSON
- * @param {string} searchFolder
- * @param {string[]} exclude
- * @param {string} jsonFilePath
*/
-function main(searchFolder, exclude, jsonFilePath) {
+function main(searchFolder: string, exclude: string[], jsonFilePath: string) {
const allFiles = getAllFilesInFolder(searchFolder, exclude);
- const json = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
+ const json = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8')) as Record;
const keys = Object.keys(json);
const validKeys = getKeysInFiles(allFiles, keys);
const filteredJson = filterJsonByKeys(json, validKeys);
fs.writeFileSync(jsonFilePath, JSON.stringify(filteredJson, null, 2));
+ console.log();
console.log(`Removed unused strings ✨`);
}
@@ -101,4 +86,5 @@ function main(searchFolder, exclude, jsonFilePath) {
// process.exit(1);
// }
-main('./src/', ['src/paraglide'], './messages/en.json');
+// TODO - Optimize also for other languages
+main('./src/', ['src/modules/i18n/paraglide'], `./messages/en.json`);
diff --git a/webapp/src/modules/i18n/remove-unused-strings.ts.rej b/webapp/src/modules/i18n/remove-unused-strings.ts.rej
new file mode 100644
index 00000000..d7599667
--- /dev/null
+++ b/webapp/src/modules/i18n/remove-unused-strings.ts.rej
@@ -0,0 +1,23 @@
+diff a/webapp/src/modules/i18n/remove-unused-strings.ts b/webapp/src/modules/i18n/remove-unused-strings.ts (rejected hunks)
+@@ -44,18 +31,14 @@ function getKeysInFiles(files, keys) {
+ keys.filter((k) => fileContent.includes(k)).forEach((k) => usedKeys.add(k));
+ });
+
+- return Array.from(usedKeys);
++ return Array.from(usedKeys) as string[];
+ }
+
+ /**
+ * Filter the JSON file by removing keys not present in the valid keys list
+- * @param {Record} json
+- * @param {string[]} validKeys
+- * @returns {Record}
+ */
+-function filterJsonByKeys(json, validKeys) {
+- /** @type {Record} */
+- const newJson = {};
++function filterJsonByKeys(json: Record, validKeys: string[]) {
++ const newJson: Record = {};
+
+ const SCHEMA_KEY = '$schema';
+ if (SCHEMA_KEY in json) {
diff --git a/webapp/src/lib/keypairoom/keypair.ts b/webapp/src/modules/keypairoom/keypair.ts
similarity index 85%
rename from webapp/src/lib/keypairoom/keypair.ts
rename to webapp/src/modules/keypairoom/keypair.ts
index 1b3df8a6..c190e07b 100644
--- a/webapp/src/lib/keypairoom/keypair.ts
+++ b/webapp/src/modules/keypairoom/keypair.ts
@@ -1,13 +1,9 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
import type { UserChallenges } from './userQuestions';
import { zencode_exec } from 'zenroom';
-import keypairoomClient from '../../../zenflows-crypto/src/keypairoomClient-8-9-10-11-12.zen?raw';
-import keypairoomClientRecreateKeys from '../../../zenflows-crypto/src/keypairoomClientRecreateKeys.zen?raw';
-import matchKeys from '../../../client_zencode/keypairoom/match_pubkeys_secretkeys.zen?raw';
-import { pb } from '$lib/pocketbase';
+import keypairoomClient from '$zencode/keypairoom/keypairoomClient-8-9-10-11-12.zen?raw';
+import keypairoomClientRecreateKeys from '$zencode/keypairoom/keypairoomClientRecreateKeys.zen?raw';
+import matchKeys from '$zencode/keypairoom/match_pubkeys_secretkeys.zen?raw';
+import { pb } from '@/pocketbase';
import { browser } from '$app/environment';
import _ from 'lodash';
import type { PublicKeys } from './utils';
diff --git a/webapp/src/modules/keypairoom/userQuestions.ts b/webapp/src/modules/keypairoom/userQuestions.ts
new file mode 100644
index 00000000..a009dd7f
--- /dev/null
+++ b/webapp/src/modules/keypairoom/userQuestions.ts
@@ -0,0 +1,43 @@
+import z from 'zod';
+import { m } from '@/i18n';
+import _ from 'lodash';
+import { String } from 'effect';
+
+//
+
+export const userChallengesSchema = z
+ .object({
+ whereParentsMet: z.string(),
+ nameFirstPet: z.string(),
+ whereHomeTown: z.string(),
+ nameFirstTeacher: z.string(),
+ nameMotherMaid: z.string()
+ })
+ .partial()
+ .refine((data) => {
+ return Object.values(data).filter(isStringNonEmpty).length >= 3;
+ }, 'AT_LEAST_THREE_QUESTIONS');
+
+export type UserChallenges = z.infer;
+export type UserChallenge = keyof UserChallenges;
+
+export const userChallenges: Array<{ id: UserChallenge; text: string }> = [
+ { id: 'whereParentsMet', text: m.whereParentsMet() },
+ { id: 'nameFirstPet', text: m.nameFirstPet() },
+ { id: 'whereHomeTown', text: m.whereHomeTown() },
+ { id: 'nameFirstTeacher', text: m.nameFirstTeacher() },
+ { id: 'nameMotherMaid', text: m.nameMotherMaid() }
+];
+
+//
+
+const ZENROOM_NULL = 'null';
+
+export function formatAnswersForZenroom(data: UserChallenges): UserChallenges {
+ return _.mapValues(data, (v) => (isStringNonEmpty(v) ? v : ZENROOM_NULL));
+}
+
+function isStringNonEmpty(string: string | undefined) {
+ if (!string) return false;
+ else return String.isNonEmpty(string);
+}
diff --git a/webapp/src/lib/keypairoom/utils.ts b/webapp/src/modules/keypairoom/utils.ts
similarity index 80%
rename from webapp/src/lib/keypairoom/utils.ts
rename to webapp/src/modules/keypairoom/utils.ts
index 088e14e8..f9ae859d 100644
--- a/webapp/src/lib/keypairoom/utils.ts
+++ b/webapp/src/modules/keypairoom/utils.ts
@@ -1,16 +1,12 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import { pb } from '$lib/pocketbase';
+import { pb } from '@/pocketbase';
import {
Collections,
type UsersPublicKeysRecord,
type UsersPublicKeysResponse
-} from '$lib/pocketbase/types';
+} from '@/pocketbase/types';
import _ from 'lodash';
import type { Keypair } from './keypair';
-import { createSessionStorageHandlers } from '$lib/utils/sessionStorage';
+import { createSessionStorageHandlers } from '@/utils/sessionStorage';
export type PublicKeys = Omit;
@@ -23,12 +19,12 @@ export function getPublicKeysFromKeypair(keypair: Keypair): PublicKeys {
return publicKeys;
}
-export async function getUserPublicKeys(userId: string | undefined = undefined) {
+export async function getUserPublicKeys(userId: string | undefined = undefined, fetchFn = fetch) {
const id = userId ?? pb.authStore.model?.id ?? '';
try {
return await pb
.collection(Collections.UsersPublicKeys)
- .getFirstListItem(`owner.id = '${id}'`);
+ .getFirstListItem(`owner.id = '${id}'`, { fetch: fetchFn });
} catch (e) {
return undefined;
}
diff --git a/webapp/src/modules/organizations/components/index.ts b/webapp/src/modules/organizations/components/index.ts
new file mode 100644
index 00000000..cb720f90
--- /dev/null
+++ b/webapp/src/modules/organizations/components/index.ts
@@ -0,0 +1,6 @@
+import OrganizationAvatar from './organizationAvatar.svelte';
+import OrganizationTabs from './organizationTabs.svelte';
+import OrganizationBreadcrumbs from './organizationBreadcrumbs.svelte';
+import OrganizationLayout from './organizationLayout.svelte';
+
+export { OrganizationAvatar, OrganizationTabs, OrganizationBreadcrumbs, OrganizationLayout };
diff --git a/webapp/src/modules/organizations/components/organizationAvatar.svelte b/webapp/src/modules/organizations/components/organizationAvatar.svelte
new file mode 100644
index 00000000..93bfce02
--- /dev/null
+++ b/webapp/src/modules/organizations/components/organizationAvatar.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/webapp/src/modules/organizations/components/organizationBreadcrumbs.svelte b/webapp/src/modules/organizations/components/organizationBreadcrumbs.svelte
new file mode 100644
index 00000000..d185dbb8
--- /dev/null
+++ b/webapp/src/modules/organizations/components/organizationBreadcrumbs.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/webapp/src/modules/organizations/components/organizationLayout.svelte b/webapp/src/modules/organizations/components/organizationLayout.svelte
new file mode 100644
index 00000000..964221c2
--- /dev/null
+++ b/webapp/src/modules/organizations/components/organizationLayout.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+ {org.name}
+ {#snippet bottom()}
+
+ {/snippet}
+
+
+
+ {@render children?.()}
+
diff --git a/webapp/src/modules/organizations/components/organizationTabs.svelte b/webapp/src/modules/organizations/components/organizationTabs.svelte
new file mode 100644
index 00000000..c85fa0a0
--- /dev/null
+++ b/webapp/src/modules/organizations/components/organizationTabs.svelte
@@ -0,0 +1,28 @@
+
+
+
diff --git a/webapp/src/modules/organizations/generate.roles-list.ts b/webapp/src/modules/organizations/generate.roles-list.ts
new file mode 100644
index 00000000..e254ebcb
--- /dev/null
+++ b/webapp/src/modules/organizations/generate.roles-list.ts
@@ -0,0 +1,25 @@
+import fs from 'fs';
+import path from 'node:path';
+import { formatCode, GENERATED, initAdminPocketbase, logCodegenResult } from '@/utils/codegen';
+
+//
+
+const TYPE_NAME = 'OrgRole';
+const OBJECT_NAME = `${TYPE_NAME}s`;
+
+const pb = await initAdminPocketbase();
+const rolesRecords = await pb.collection('orgRoles').getFullList();
+const rolesEntries = rolesRecords.map((r) => `${r.name.toUpperCase()}: '${r.name}'`);
+
+const code = `
+export const ${OBJECT_NAME} = {
+ ${rolesEntries.join(',\n')}
+} as const
+
+export type ${TYPE_NAME} = typeof ${OBJECT_NAME} [keyof typeof ${OBJECT_NAME}];
+`;
+
+const formattedCode = await formatCode(code);
+const filePath = path.join(import.meta.dirname, `roles-list.${GENERATED}.ts`);
+fs.writeFileSync(filePath, formattedCode);
+logCodegenResult('organization roles list', filePath);
diff --git a/webapp/src/lib/organizations/guards.ts b/webapp/src/modules/organizations/guards.ts
similarity index 100%
rename from webapp/src/lib/organizations/guards.ts
rename to webapp/src/modules/organizations/guards.ts
diff --git a/webapp/src/modules/organizations/guards.ts.rej b/webapp/src/modules/organizations/guards.ts.rej
new file mode 100644
index 00000000..8b33082c
--- /dev/null
+++ b/webapp/src/modules/organizations/guards.ts.rej
@@ -0,0 +1,9 @@
+diff a/webapp/src/modules/organizations/guards.ts b/webapp/src/modules/organizations/guards.ts (rejected hunks)
+@@ -1,6 +1,6 @@
+ import { error } from '@sveltejs/kit';
+ import { verifyUserMembership, verifyUserRole } from './verify-authorizations';
+-import type { OrgRole } from './roles';
++import type { OrgRole } from '.';
+
+ export async function blockNonMembers(organizationId: string, fetchFn = fetch) {
+ const { isMember } = await verifyUserMembership(organizationId, fetchFn);
diff --git a/webapp/src/lib/organizations/index.ts b/webapp/src/modules/organizations/index.ts
similarity index 60%
rename from webapp/src/lib/organizations/index.ts
rename to webapp/src/modules/organizations/index.ts
index 0bcce843..a548fe84 100644
--- a/webapp/src/lib/organizations/index.ts
+++ b/webapp/src/modules/organizations/index.ts
@@ -1,11 +1,7 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
import ProtectedOrgUI from './protectedOrgUI.svelte';
export { ProtectedOrgUI };
-export * from './roles';
+export * from './roles-list.generated';
export * from './verify-authorizations';
export * from './utils';
diff --git a/webapp/src/lib/organizations/invites/index.ts b/webapp/src/modules/organizations/invites/index.ts
similarity index 100%
rename from webapp/src/lib/organizations/invites/index.ts
rename to webapp/src/modules/organizations/invites/index.ts
diff --git a/webapp/src/modules/organizations/invites/index.ts.rej b/webapp/src/modules/organizations/invites/index.ts.rej
new file mode 100644
index 00000000..863060d5
--- /dev/null
+++ b/webapp/src/modules/organizations/invites/index.ts.rej
@@ -0,0 +1,7 @@
+diff a/webapp/src/modules/organizations/invites/index.ts b/webapp/src/modules/organizations/invites/index.ts (rejected hunks)
+@@ -1,4 +1,4 @@
+-import { createSessionStorageHandlers } from '$lib/utils/sessionStorage';
++import { createSessionStorageHandlers } from '@/utils/sessionStorage';
+
+ type OrganizationInviteSession = {
+ organizationId: string;
diff --git a/webapp/src/modules/organizations/links.ts b/webapp/src/modules/organizations/links.ts
new file mode 100644
index 00000000..785e672d
--- /dev/null
+++ b/webapp/src/modules/organizations/links.ts
@@ -0,0 +1,36 @@
+import { m } from '@/i18n';
+import { Cog, Home, Users } from 'lucide-svelte';
+import type { OrgRole } from '.';
+import type { LinkWithIcon } from '@/components/types';
+
+//
+
+export function createOrganizationLinks(
+ organizationId: string,
+ userRole: OrgRole = 'member'
+): LinkWithIcon[] {
+ const base = (path = '') => `/my/organizations/${organizationId}${path}`;
+
+ const links: LinkWithIcon[] = [
+ {
+ title: m.Home(),
+ href: base(),
+ icon: Home
+ },
+ {
+ title: m.Members(),
+ href: base('/members'),
+ icon: Users
+ }
+ ];
+
+ if (userRole == 'owner') {
+ links.push({
+ title: m.Settings(),
+ href: base('/settings'),
+ icon: Cog
+ });
+ }
+
+ return links;
+}
diff --git a/webapp/src/modules/organizations/protectedOrgUI.svelte b/webapp/src/modules/organizations/protectedOrgUI.svelte
new file mode 100644
index 00000000..8dd10348
--- /dev/null
+++ b/webapp/src/modules/organizations/protectedOrgUI.svelte
@@ -0,0 +1,18 @@
+
+
+{#await verifyUserRole(orgId, roles) then response}
+ {#if response.hasRole}
+ {@render children?.()}
+ {/if}
+{/await}
diff --git a/webapp/src/lib/organizations/utils.ts b/webapp/src/modules/organizations/utils.ts
similarity index 100%
rename from webapp/src/lib/organizations/utils.ts
rename to webapp/src/modules/organizations/utils.ts
diff --git a/webapp/src/modules/organizations/utils.ts.rej b/webapp/src/modules/organizations/utils.ts.rej
new file mode 100644
index 00000000..4e23ca72
--- /dev/null
+++ b/webapp/src/modules/organizations/utils.ts.rej
@@ -0,0 +1,11 @@
+diff a/webapp/src/modules/organizations/utils.ts b/webapp/src/modules/organizations/utils.ts (rejected hunks)
+@@ -1,6 +1,6 @@
+-import { pb } from '$lib/pocketbase';
+-import type { OrgRole } from './roles';
+-import { type OrgAuthorizationsResponse, type OrgRolesResponse } from '$lib/pocketbase/types';
++import { pb } from '@/pocketbase';
++import type { OrgRole } from '.';
++import { type OrgAuthorizationsResponse, type OrgRolesResponse } from '@/pocketbase/types';
+ import { Option as O } from 'effect';
+
+ export async function getUserRole(organizationId: string, userId: string): Promise {
diff --git a/webapp/src/lib/organizations/verify-authorizations.ts b/webapp/src/modules/organizations/verify-authorizations.ts
similarity index 100%
rename from webapp/src/lib/organizations/verify-authorizations.ts
rename to webapp/src/modules/organizations/verify-authorizations.ts
diff --git a/webapp/src/modules/organizations/verify-authorizations.ts.rej b/webapp/src/modules/organizations/verify-authorizations.ts.rej
new file mode 100644
index 00000000..89b56c21
--- /dev/null
+++ b/webapp/src/modules/organizations/verify-authorizations.ts.rej
@@ -0,0 +1,9 @@
+diff a/webapp/src/modules/organizations/verify-authorizations.ts b/webapp/src/modules/organizations/verify-authorizations.ts (rejected hunks)
+@@ -1,5 +1,5 @@
+-import { pb } from '$lib/pocketbase';
+-import type { OrgRole } from './roles';
++import { pb } from '@/pocketbase';
++import type { OrgRole } from '.';
+
+ /* Reference: admin/pb_hooks/organizations.routes.pb.js */
+
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/config.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/config.ts
new file mode 100644
index 00000000..d6d4dac2
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/config.ts
@@ -0,0 +1,47 @@
+// import { FieldType as FT, type CollectionConfig } from '@/pocketbase/collections-config/types';
+// import { Schema as S } from '@effect/schema';
+// import { FileSchema, UrlSchema } from '@/utils/schema';
+// import type { ConverterConfig } from './types';
+// import pocketbaseConfig from '@/pocketbase/schema/export-pb-schema/pb-schema.generated.json'; // TODO - Pass from config
+
+// //
+
+// export const config: ConverterConfig = {
+// pocketbaseConfig: pocketbaseConfig as CollectionConfig[],
+
+// fieldTypeToBaseSchema: {
+// [FT.TEXT]: {
+// schema: S.String,
+// filters: {
+// min: (v) => S.minLength(S.validateSync(S.Number)(v)),
+// max: (v) => S.maxLength(S.validateSync(S.Number)(v)),
+// pattern: (v) => {
+// const pattern = S.validateSync(S.String)(v);
+// const regex = new RegExp(`|${pattern}`); // Add a "|" pipe to the regex to allow for empty string (Ciscoheat suggestion)
+// return S.pattern(regex);
+// }
+// }
+// },
+// [FT.NUMBER]: {
+// schema: S.Number,
+// filters: {
+// min: (v) => S.greaterThan(S.validateSync(S.Number)(v)),
+// max: (v) => S.lessThan(S.validateSync(S.Number)(v))
+// }
+// },
+// [FT.EDITOR]: { schema: S.String },
+// [FT.BOOL]: { schema: S.Boolean },
+// [FT.FILE]: { schema: FileSchema },
+// [FT.SELECT]: { schema: S.String },
+// [FT.RELATION]: { schema: S.String },
+// [FT.JSON]: { schema: S.Record(S.String, S.Unknown) },
+// [FT.URL]: { schema: UrlSchema },
+// [FT.EMAIL]: { schema: S.String },
+// [FT.DATE]: { schema: S.String }
+// },
+
+// arrayFieldSchemaFilters: {
+// maxSelect: (v: unknown) => S.maxItems(S.validateSync(S.Number)(v)),
+// minSelect: (v: unknown) => S.minItems(S.validateSync(S.Number)(v))
+// }
+// };
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/get.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/get.ts
new file mode 100644
index 00000000..ddbd62d0
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/get.ts
@@ -0,0 +1,16 @@
+// import GeneratedSchema from './schema.generated';
+// import GeneratedZodSchema from './ao.generated';
+
+// //
+
+// export function getCollectionJsonSchema(
+// collection: T
+// ): (typeof GeneratedSchema)[T] {
+// return GeneratedSchema[collection];
+// }
+
+// export function getCollectionZodSchema(
+// collection: T
+// ): (typeof GeneratedZodSchema)[T] {
+// return GeneratedZodSchema[collection];
+// }
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/index.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/index.ts
new file mode 100644
index 00000000..aaee31fa
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/index.ts
@@ -0,0 +1,51 @@
+// import { Effect, pipe, Record } from 'effect';
+// import fs from 'node:fs';
+// import path from 'node:path';
+
+// import { convertDbConfigToSchemas } from './procedures';
+// import type { Plugin } from 'vite';
+// import { JSONSchema } from '@effect/schema';
+// import { getCurrentWorkingDirectory } from '@/utils/fs';
+
+// import prettier from 'prettier';
+
+// //
+
+// export function convertPbSchemaToJsonSchemaPlugin(): Plugin {
+// return {
+// name: 'save_database_index',
+// buildStart: () => {
+// convertPbSchemaToJsonSchema();
+// },
+// handleHotUpdate: () => {
+// convertPbSchemaToJsonSchema();
+// }
+// };
+// }
+
+// convertPbSchemaToJsonSchema();
+
+// function convertPbSchemaToJsonSchema() {
+// pipe(
+// convertDbConfigToSchemas(),
+// Effect.map((schemas) =>
+// pipe(
+// schemas,
+// Record.fromEntries,
+// Record.map(JSONSchema.make),
+// (schema) => JSON.stringify(schema, null, 4),
+// (schema) => `export default ${schema} as const`,
+// (schema) => prettier.format(schema, { parser: 'babel-ts' }),
+// (formatPromise) => Effect.promise(() => formatPromise),
+// Effect.map((databaseSchema) => {
+// const schemaPath = path.join(
+// getCurrentWorkingDirectory(import.meta.url),
+// `schema.generated.ts`
+// );
+// fs.writeFileSync(schemaPath, databaseSchema);
+// })
+// )
+// ),
+// Effect.runPromise
+// );
+// }
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/procedures.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/procedures.ts
new file mode 100644
index 00000000..ede81805
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/procedures.ts
@@ -0,0 +1,130 @@
+// import { Option as O, pipe, Record as R, Array as A, Effect, Tuple } from 'effect';
+// import { Schema as S } from '@effect/schema';
+
+// import { FieldType as FT, type FieldConfig } from '@/pocketbase/collections-config/types';
+// import { isArrayField } from '@/pocketbase/collections-config/utils';
+
+// import { type SchemaFilter, type FieldSchemaFiltersConfig } from './types';
+// import { config } from './config';
+
+// // -- Converters
+
+// export function convertDbConfigToSchemas() {
+// return pipe(
+// config.pocketbaseConfig,
+// A.map((collectionConfig) =>
+// pipe(
+// convertFieldConfigsToObjectSchema(collectionConfig.schema),
+// Effect.map((schema) => Tuple.make(collectionConfig.name, schema))
+// )
+// ),
+// Effect.all
+// );
+// }
+
+// export function convertFieldConfigsToObjectSchema(fieldsConfig: FieldConfig[]) {
+// return pipe(
+// fieldsConfig,
+// A.map((fieldConfig) =>
+// pipe(
+// convertFieldConfigToSchema(fieldConfig),
+// // Create entries to store in record
+// Effect.map((schema) => Tuple.make(fieldConfig.name, schema))
+// )
+// ),
+// Effect.all,
+// Effect.map(R.fromEntries),
+// Effect.map(S.Struct)
+// );
+// }
+
+// export function convertFieldConfigToSchema(fieldConfig: FieldConfig) {
+// return pipe(
+// // -- base schema
+// getBaseSchema(fieldConfig),
+// // -- base filters
+// Effect.flatMap((schema) =>
+// pipe(
+// getBaseSchemaFilters(fieldConfig),
+// Effect.map((filters) => applyFiltersToSchema(schema, filters))
+// )
+// ),
+// // -- array schema
+// Effect.flatMap((schema) =>
+// Effect.if(isArrayField(fieldConfig), {
+// onFalse: () => Effect.succeed(schema),
+// onTrue: () =>
+// pipe(S.Array(schema), (arraySchema) =>
+// pipe(
+// getArraySchemaFilters(fieldConfig),
+// (arrayFilters) => applyFiltersToSchema(arraySchema, arrayFilters),
+// Effect.succeed
+// )
+// )
+// })
+// ),
+// // -- required
+// Effect.flatMap((schema) =>
+// Effect.if(fieldConfig.required, {
+// onTrue: () => Effect.succeed(schema),
+// onFalse: () => Effect.succeed(S.optional(schema))
+// })
+// )
+// );
+// }
+
+// // -- Getters
+
+// function getBaseSchema(fieldConfig: FieldConfig) {
+// return pipe(
+// getBaseSchemaConfig(fieldConfig),
+// Effect.map((baseSchemaConfig) => baseSchemaConfig.schema)
+// );
+// }
+
+// function getBaseSchemaFilters(fieldConfig: FieldConfig) {
+// return pipe(
+// getBaseSchemaConfig(fieldConfig),
+// Effect.map((baseSchemaConfig) =>
+// pipe(
+// baseSchemaConfig.filters,
+// O.fromNullable,
+// O.getOrElse(() => ({}) as FieldSchemaFiltersConfig)
+// )
+// ),
+// Effect.map((filtersConfig) =>
+// pipe(
+// Object.entries(fieldConfig.options),
+// A.filter(([optionName]) => optionName in filtersConfig),
+// A.filter(([, optionValue]) => Boolean(optionValue)),
+// A.map(([optionName, optionValue]) =>
+// pipe(filtersConfig, R.get(optionName), O.getOrThrow, (filterCreator) =>
+// filterCreator(optionValue)
+// )
+// )
+// )
+// )
+// );
+// }
+
+// function getArraySchemaFilters(fieldConfig: FieldConfig) {
+// return pipe(
+// Object.entries(fieldConfig.options),
+// A.filter(([optionName]) => optionName in config.arrayFieldSchemaFilters),
+// A.map(([optionName, optionValue]) =>
+// pipe(config.arrayFieldSchemaFilters, R.get(optionName), O.getOrThrow, (filterCreator) =>
+// filterCreator(optionValue)
+// )
+// )
+// );
+// }
+
+// function getBaseSchemaConfig(fieldConfig: FieldConfig) {
+// return R.get(config.fieldTypeToBaseSchema, fieldConfig.type as FT);
+// }
+
+// // -- Utils
+
+// function applyFiltersToSchema(schema: S.Schema.Any, filters: SchemaFilter[]) {
+// return A.reduce(filters, schema, (schema, filter) => filter(schema));
+// }
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/toZod.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/toZod.ts
new file mode 100644
index 00000000..de35c668
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/toZod.ts
@@ -0,0 +1,22 @@
+// import { Array, Effect, pipe, Record } from 'effect';
+// import GeneratedSchema from './schema.generated';
+// import { jsonSchemaToZod } from 'json-schema-to-zod';
+// import prettier from 'prettier';
+// import fs from 'node:fs';
+// import { getCurrentWorkingDirectory } from '@/utils/fs';
+
+// pipe(
+// GeneratedSchema,
+// // @ts-expect-error Type mismatch between {required: readonly string[]} and {required: string[] | boolean}
+// Record.map((jsonSchema) => jsonSchemaToZod(jsonSchema)),
+// Record.toEntries,
+// Array.map(([key, schema]) => `${key}: ${schema}`),
+// Array.join(',\n\t'),
+// (schema) => `import {z} from 'zod'; \n\n export default {${schema}} as const`,
+// (schema) => prettier.format(schema, { parser: 'babel-ts' }),
+// (res) => Effect.promise(() => res),
+// Effect.tap((schemaString) =>
+// fs.writeFileSync(getCurrentWorkingDirectory(import.meta.url) + '/ao.generated.ts', schemaString)
+// ),
+// Effect.runPromise
+// );
diff --git a/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/types.ts b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/types.ts
new file mode 100644
index 00000000..f7e7d3f1
--- /dev/null
+++ b/webapp/src/modules/pocketbase/_archive/pb-schema-to-json-schema/types.ts
@@ -0,0 +1,37 @@
+// import type { Schema as S } from '@effect/schema'; // eslint-disable-line @typescript-eslint/no-unused-vars
+// import type { FieldType, AnyCollectionModel } from '@/pocketbase/collections-models/types';
+
+// /* Filters */
+
+// // -- effect
+
+// export type SchemaFilter = (self: S) => S.filter;
+
+// export type SchemaFilterCreator = (value: V) => (self: S) => S.filter;
+
+// // -- pocketbase
+
+// export type FieldSchemaFiltersConfig = Record<
+// string,
+// SchemaFilterCreator
+// >;
+
+// export type ArrayFieldSchemaFiltersConfig = Record<
+// string,
+// SchemaFilterCreator, ReadonlyArray, unknown>, unknown>
+// >;
+
+// export type FieldSchemaConfig = {
+// schema: S;
+// filters?: FieldSchemaFiltersConfig;
+// };
+
+// export type FieldTypeToSchemaConfig = Record>;
+
+// /* Effect */
+
+// export type ConverterConfig = {
+// readonly fieldTypeToBaseSchema: FieldTypeToSchemaConfig;
+// readonly arrayFieldSchemaFilters: ArrayFieldSchemaFiltersConfig;
+// readonly pocketbaseConfig: AnyCollectionModel[];
+// };
diff --git a/webapp/src/modules/pocketbase/collections-models/generate.collections-models.ts b/webapp/src/modules/pocketbase/collections-models/generate.collections-models.ts
new file mode 100644
index 00000000..a3dfa8d1
--- /dev/null
+++ b/webapp/src/modules/pocketbase/collections-models/generate.collections-models.ts
@@ -0,0 +1,129 @@
+import { type CollectionModel } from 'pocketbase';
+import fs from 'fs';
+import 'dotenv/config';
+import path from 'node:path';
+import {
+ EXPORT_TYPE,
+ formatCode,
+ GENERATED,
+ initAdminPocketbase,
+ logCodegenResult,
+ SEPARATOR
+} from '@/utils/codegen';
+import { pipe, Array as A, String, Record } from 'effect';
+import { capitalize, merge } from 'lodash';
+import JsonToTS from 'json-to-ts';
+
+/* Setup */
+
+const pb = await initAdminPocketbase();
+const models = await pb.collections.getFullList();
+
+/* Codegen */
+
+const SCHEMA_FIELD = `SchemaField`;
+const COLLECTION_MODEL = `CollectionModel`;
+const IMPORT_STATEMENTS = `
+import type { ${SCHEMA_FIELD}, ${COLLECTION_MODEL} } from 'pocketbase'
+import type { SetFieldType, Simplify } from 'type-fest';
+`;
+
+const schemaFieldTypes = getFieldTypeNames(models);
+const schemaFieldType = `${EXPORT_TYPE} SchemaFieldType = ${schemaFieldTypes.map((t) => JSON.stringify(t)).join(' | ')}`;
+const schemaFieldOptionsTypesData = schemaFieldTypes.map((f) =>
+ createFieldOptionsTypeData(f, models)
+);
+const schemaFields = `export type SchemaFields = {
+ ${schemaFieldOptionsTypesData.map(({ name, key }) => `${key}: ${name}`).join('\n')}
+}`;
+const anySchemaField = `export type AnySchemaField = ${schemaFieldOptionsTypesData.map(({ name }) => name).join(' | ')}`;
+
+const ANY_COLLECTION_MODEL = `AnyCollectionModel`;
+const anyCollectionModel = `export type ${ANY_COLLECTION_MODEL} = Simplify>;`;
+
+const code = [
+ IMPORT_STATEMENTS,
+ SEPARATOR,
+ schemaFieldType,
+ anySchemaField,
+ schemaFields,
+ ...schemaFieldOptionsTypesData.map((data) => data.code),
+ SEPARATOR,
+ anyCollectionModel,
+ collectionName(models),
+ sanitizeCollectionsModels(models)
+].join('\n\n');
+
+/* Export */
+
+const formattedCode = await formatCode(code);
+const filePath = path.resolve(import.meta.dirname, `collections-models.${GENERATED}.ts`);
+fs.writeFileSync(filePath, formattedCode);
+logCodegenResult('collections models and helper types', filePath);
+
+/* Helper functions */
+
+function sanitizeCollectionsModels(models: CollectionModel[]) {
+ // Hiding API rules to reduce leaked information
+ const sanitizedModels = models.map(
+ Record.map((v, k) => {
+ if (k.includes('Rule')) return '';
+ else return v;
+ })
+ );
+
+ return `export const CollectionsModels = ${JSON.stringify(sanitizedModels, null, 2)} as ${ANY_COLLECTION_MODEL}[]`;
+}
+
+function collectionName(models: CollectionModel[]): string {
+ const names = models.map((m) => m.name);
+ return `export type CollectionName = ${names.map((n) => JSON.stringify(n)).join(' | ')}`;
+}
+
+//
+
+function getFieldTypeNames(models: CollectionModel[]) {
+ return pipe(
+ models.flatMap((model) => model.schema),
+ A.map((field) => field.type),
+ A.dedupe
+ );
+}
+
+function createFieldOptionsTypeData(
+ fieldType: string,
+ models: CollectionModel[]
+): GeneratedTypeData {
+ const typeName = capitalize(fieldType) + SCHEMA_FIELD;
+ return pipe(
+ models
+ .flatMap((m) => m.schema)
+ .filter((f) => f.type == fieldType)
+ .map((f) => f.options),
+ // merging data in a single object
+ (fieldsSchemas) => merge({}, ...fieldsSchemas),
+ // converting to ts
+ (data) => JsonToTS(data, { useTypeAlias: true })[0],
+ //
+ (code) => {
+ const typeOnly = code.split('=')[1].trim();
+ const removeEmptyObject = typeOnly == `{\n}` ? 'Record' : typeOnly;
+ const newCode = `export type ${typeName} = ${SCHEMA_FIELD} & { type: "${fieldType}"; options: Partial<${removeEmptyObject}>; unique: boolean }`;
+ return newCode;
+ },
+ // small fix
+ String.replace('any[]', 'string[]'),
+ //
+ (code) => ({
+ code,
+ name: typeName,
+ key: fieldType
+ })
+ );
+}
+
+type GeneratedTypeData = {
+ code: string;
+ name: string;
+ key: string;
+};
diff --git a/webapp/src/modules/pocketbase/collections-models/index.ts b/webapp/src/modules/pocketbase/collections-models/index.ts
new file mode 100644
index 00000000..628a0fb4
--- /dev/null
+++ b/webapp/src/modules/pocketbase/collections-models/index.ts
@@ -0,0 +1,52 @@
+export * from './collections-models.generated';
+
+//
+
+import {
+ CollectionsModels,
+ type CollectionName,
+ type AnySchemaField,
+ type FileSchemaField,
+ type RelationSchemaField,
+ type SelectSchemaField,
+ type AnyCollectionModel
+} from './collections-models.generated';
+import { Array, Option, pipe } from 'effect';
+
+//
+
+export function getCollectionModel(collection: CollectionName): AnyCollectionModel {
+ return pipe(
+ CollectionsModels as AnyCollectionModel[],
+ Array.findFirst((model) => model.name == collection),
+ Option.getOrThrowWith(() => new CollectionNotFoundError())
+ );
+}
+
+export function getCollectionNameFromId(id: string): CollectionName {
+ return pipe(
+ CollectionsModels,
+ Array.findFirst((model) => model.id == id),
+ Option.getOrThrowWith(() => new CollectionNotFoundError()),
+ (model) => model.name as CollectionName
+ );
+}
+
+class CollectionNotFoundError extends Error {}
+
+//
+
+export function isArrayField(
+ fieldConfig: AnySchemaField
+): fieldConfig is FileSchemaField | SelectSchemaField | RelationSchemaField {
+ const type = fieldConfig.type;
+ if (type !== 'select' && type !== 'relation' && type !== 'file') return false;
+ if (fieldConfig.options.maxSelect === 1) return false;
+ else return true;
+}
+
+export function getRelationFields(collection: C): string[] {
+ return getCollectionModel(collection)
+ .schema.filter((field) => field.type == 'relation')
+ .map((field) => field.name);
+}
diff --git a/webapp/src/lib/pocketbase/index.ts b/webapp/src/modules/pocketbase/index.ts
similarity index 68%
rename from webapp/src/lib/pocketbase/index.ts
rename to webapp/src/modules/pocketbase/index.ts
index 2573ec8f..867b93d4 100644
--- a/webapp/src/lib/pocketbase/index.ts
+++ b/webapp/src/modules/pocketbase/index.ts
@@ -1,11 +1,9 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
import { PUBLIC_POCKETBASE_URL } from '$env/static/public';
import PocketBase from 'pocketbase';
import { writable } from 'svelte/store';
-import type { TypedPocketBase, UsersResponse } from './types';
+import type { TypedPocketBase, UsersResponse } from '@/pocketbase/types';
+
+//
export const pb = new PocketBase(PUBLIC_POCKETBASE_URL) as TypedPocketBase;
diff --git a/webapp/src/modules/pocketbase/query/index.ts b/webapp/src/modules/pocketbase/query/index.ts
new file mode 100644
index 00000000..7616b5b3
--- /dev/null
+++ b/webapp/src/modules/pocketbase/query/index.ts
@@ -0,0 +1,165 @@
+import {
+ getCollectionModel,
+ type CollectionName,
+ type SchemaFieldType
+} from '@/pocketbase/collections-models';
+import type { CollectionExpands, CollectionResponses, RecordIdString } from '@/pocketbase/types';
+import type { KeyOf } from '@/utils/types';
+import PocketBase, {
+ type ListResult,
+ type RecordFullListOptions,
+ type RecordListOptions
+} from 'pocketbase';
+import { pb } from '@/pocketbase';
+import type { Simplify } from 'type-fest';
+import { Option, String, Array } from 'effect';
+
+//
+
+export type ExpandQueryOption = KeyOf[];
+
+type ResolveExpandOption> = Partial<
+ Pick
+>;
+
+export type QueryResponse<
+ C extends CollectionName,
+ Expand extends ExpandQueryOption = never
+> = CollectionResponses[C] &
+ Simplify<{
+ expand?: ResolveExpandOption;
+ }>;
+
+//
+
+type SortOption = [string, SortOrder];
+type SortOrder = 'ASC' | 'DESC';
+export const DEFAULT_SORT_ORDER: SortOrder = 'ASC';
+
+type PocketbaseListOptions = Simplify;
+
+//
+
+export type PocketbaseQueryOptions> = {
+ expand: E;
+ filter: string;
+ exclude: RecordIdString[];
+ search: string;
+ sort: SortOption;
+ // TODO - Improve type safety with keyof CollectionResponses[C] (currently it does not work cause of svelte issue)
+ // TODO - Improve to handle multiple sorts
+ perPage: number;
+ requestKey: string | null;
+ fetch: typeof fetch;
+ pocketbase: PocketBase;
+};
+
+//
+
+export class PocketbaseQuery> {
+ constructor(
+ public collection: C,
+ public options: Partial> = {}
+ ) {}
+
+ get pocketbase(): PocketBase {
+ return this.options.pocketbase ?? pb;
+ }
+
+ // Filters
+
+ get baseFilter(): Option.Option {
+ return Option.fromNullable(this.options.filter);
+ }
+
+ get excludeFilter(): Option.Option {
+ return Option.fromNullable(this.options.exclude).pipe(
+ Option.map((ids) => ids.map((id) => `id != '${id}'`).join(' && '))
+ );
+ }
+
+ get searchFilter(): Option.Option {
+ return Option.fromNullable(this.options.search).pipe(
+ Option.map((searchText) => {
+ const allowedFieldTypes: SchemaFieldType[] = ['text', 'editor', 'select', 'email', 'url'];
+ const fieldNames = getCollectionModel(this.collection)
+ .schema.filter((field) => allowedFieldTypes.includes(field.type))
+ .map((field) => field.name);
+ if (this.collection == 'users') fieldNames.push('email');
+ return fieldNames.map((f) => `${f} ~ "${searchText}"`).join(' || ');
+ })
+ );
+ }
+
+ // Sort
+
+ get sortOption(): SortOption {
+ const { sort } = this.options;
+ return sort ?? ['created', 'DESC'];
+ }
+
+ // Options
+
+ get filterPbOption(): Option.Option {
+ const filters = [this.baseFilter, this.excludeFilter, this.searchFilter]
+ .filter(Option.isSome)
+ .map(Option.getOrThrow)
+ .filter(String.isNonEmpty);
+ if (filters.length == 0) return Option.none();
+
+ const filter = filters.map((filter) => `( ${filter} )`).join(' && ');
+ return Option.some(filter);
+ }
+
+ get expandPbOption(): Option.Option {
+ return Option.fromNullable(this.options.expand).pipe(Option.map((expand) => expand.join(', ')));
+ }
+
+ get sortPbOption(): string {
+ return Array.reverse(this.sortOption).join('').replace('ASC', '+').replace('DESC', '-');
+ }
+
+ get pocketbaseListOptions(): PocketbaseListOptions {
+ const { fetch: fetchFn = fetch, requestKey = null, perPage } = this.options;
+
+ const options: PocketbaseListOptions = {
+ requestKey,
+ sort: this.sortPbOption,
+ fetch: fetchFn
+ };
+
+ if (Option.isSome(this.expandPbOption)) options.expand = this.expandPbOption.value;
+ if (Option.isSome(this.filterPbOption)) options.filter = this.filterPbOption.value;
+ if (perPage) options.perPage = perPage;
+
+ return options;
+ }
+
+ //
+
+ getFullList(): Promise[]> {
+ return pb.collection(this.collection).getFullList(this.pocketbaseListOptions);
+ }
+
+ getList(currentPage: number): Promise>> {
+ const { perPage } = this.options;
+ return pb.collection(this.collection).getList(currentPage, perPage, this.pocketbaseListOptions);
+ }
+
+ // Utils
+
+ sortBy(sortOption: SortOption) {
+ this.options.sort = sortOption;
+ }
+
+ getFlippedSort() {
+ return [
+ this.sortOption[0],
+ this.sortOption[1] == DEFAULT_SORT_ORDER ? 'DESC' : 'ASC'
+ ] as SortOption;
+ }
+
+ flipSort() {
+ this.options.sort = this.getFlippedSort();
+ }
+}
diff --git a/webapp/src/modules/pocketbase/subscriptions/index.ts b/webapp/src/modules/pocketbase/subscriptions/index.ts
new file mode 100644
index 00000000..64d79bcb
--- /dev/null
+++ b/webapp/src/modules/pocketbase/subscriptions/index.ts
@@ -0,0 +1,70 @@
+import type { ExpandQueryOption } from '@/pocketbase/query';
+import {
+ getCollectionModel,
+ getCollectionNameFromId,
+ type CollectionName
+} from '../collections-models';
+import { onDestroy } from 'svelte';
+import type { MaybePromise } from '@/utils/types';
+import type { RecordSubscription } from 'pocketbase';
+import { pb } from '@/pocketbase';
+import type { CollectionResponses } from '../types';
+
+//
+
+type SubscriptionCallback = (
+ data: RecordSubscription
+) => MaybePromise;
+
+//
+
+export function setupComponentPocketbaseSubscriptions(init: {
+ collection: C;
+ callback: () => MaybePromise;
+ expandOption?: ExpandQueryOption;
+ other?: CollectionName[];
+}) {
+ const { collection, callback, expandOption = [], other = [] } = init;
+ const collections: CollectionName[] = [
+ collection,
+ ...getRelatedCollectionsFromExpandOption(collection, expandOption),
+ ...other
+ ];
+ for (const c of collections) {
+ setupComponentSubscription(c, callback);
+ }
+}
+
+function setupComponentSubscription(
+ collection: C,
+ callback: SubscriptionCallback
+) {
+ const unsubscribeFunctionPromise = pb.collection(collection).subscribe('*', callback);
+ onDestroy(async () => {
+ (await unsubscribeFunctionPromise)();
+ });
+}
+
+function getRelatedCollectionsFromExpandOption(
+ collection: C,
+ expand: ExpandQueryOption
+): CollectionName[] {
+ const INVERSE_RELATION_KEY = '_via_';
+
+ const inverseRelations = expand
+ .filter((expandItem) => expandItem.includes(INVERSE_RELATION_KEY))
+ .map((expandItem) => expandItem.split(INVERSE_RELATION_KEY)[0] as CollectionName);
+
+ const directRelations = expand
+ .filter((expandItem) => !expandItem.includes(INVERSE_RELATION_KEY))
+ .map((expandItem) => {
+ const relationField = getCollectionModel(collection)
+ .schema.filter((field) => field.type == 'relation')
+ .find((field) => field.name == expandItem);
+
+ if (!relationField) throw new Error('relation_field_not_found');
+ return getCollectionNameFromId(relationField.options.collectionId!);
+ });
+
+ return [...inverseRelations, ...directRelations];
+}
diff --git a/webapp/src/modules/pocketbase/types/generate.types-extra.ts b/webapp/src/modules/pocketbase/types/generate.types-extra.ts
new file mode 100644
index 00000000..05354b10
--- /dev/null
+++ b/webapp/src/modules/pocketbase/types/generate.types-extra.ts
@@ -0,0 +1,230 @@
+import {
+ CollectionsModels,
+ isArrayField,
+ type AnyCollectionModel,
+ type AnySchemaField,
+ type RelationSchemaField
+} from '@/pocketbase/collections-models';
+import { camelCase } from 'lodash';
+import { capitalize } from 'effect/String';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import _ from 'lodash';
+import assert from 'node:assert';
+import { EXPORT_TYPE, formatCode, GENERATED, logCodegenResult, SEPARATOR } from '@/utils/codegen';
+
+/* CONSTS */
+
+const COLLECTION_RESPONSES = 'CollectionResponses';
+const COLLECTION = 'Collection';
+
+const FORM_DATA = 'FormData';
+const EXPAND = 'Expand';
+const ZOD_RAW_SHAPE = 'ZodRawShape';
+const RELATED_COLLECTIONS = 'RelatedCollections';
+
+const RECORD_NEVER = 'Record';
+
+const IMPORT_STATEMENTS = `
+import type { ${COLLECTION_RESPONSES} } from '@/pocketbase/types/index.generated'
+import type {z} from 'zod'
+`;
+
+/* Functions */
+
+main();
+
+async function main() {
+ const sortedCollections = _.sortBy(CollectionsModels, (d) => d.name);
+
+ const formDataTypes = sortedCollections.map(createCollectionFormDataType);
+ const formDataIndexType = createIndexType(formDataTypes, FORM_DATA);
+
+ const expandTypes = sortedCollections.map(createCollectionExpand);
+ const expandIndexType = createIndexType(expandTypes, EXPAND, true);
+
+ const zodTypes = sortedCollections.map(createCollectionZodRawType);
+ const zodIndexType = createIndexType(zodTypes, ZOD_RAW_SHAPE, true);
+
+ const relatedCollectionTypes = sortedCollections.map(createCollectionRelatedCollections);
+ const relatedCollectionsIndexType = createIndexType(relatedCollectionTypes, RELATED_COLLECTIONS);
+
+ const code = [
+ IMPORT_STATEMENTS,
+ SEPARATOR,
+ formDataIndexType,
+ ...formDataTypes.map((t) => t.code),
+ SEPARATOR,
+ zodIndexType,
+ ...zodTypes.map((t) => t.code),
+ SEPARATOR,
+ expandIndexType,
+ ...expandTypes.map((t) => t.code),
+ SEPARATOR,
+ relatedCollectionsIndexType,
+ ...relatedCollectionTypes.map((t) => t.code)
+ ].join('\n\n');
+
+ const formattedCode = await formatCode(code);
+ const filePath = path.join(import.meta.dirname, `extra.${GENERATED}.ts`);
+ await fs.writeFile(filePath, formattedCode);
+ logCodegenResult('extra types', filePath);
+}
+
+//
+
+function createCollectionFormDataType(model: AnyCollectionModel): GeneratedCollectionTypeData {
+ const collectionName = model.name;
+ const typeName = capitalize(camelCase(model.name)) + FORM_DATA;
+
+ const fields = model.schema.map((f) => {
+ let type: string;
+ if (f.type == 'number') type = 'number';
+ else if (f.type == 'bool') type = 'boolean';
+ else if (f.type == 'date') type = 'string';
+ else if (f.type == 'editor') type = 'string';
+ else if (f.type == 'email') type = 'string';
+ else if (f.type == 'file') type = 'File';
+ else if (f.type == 'json') type = 'unknown';
+ else if (f.type == 'relation') type = 'string';
+ else if (f.type == 'text') type = 'string';
+ else if (f.type == 'url') type = 'string';
+ else if (f.type == 'select' && f.options.values)
+ type = f.options.values.map((v) => `"${v}"`).join(' | ');
+ else throw new UnhandledFieldTypeError();
+ if (isArrayField(f)) type = `(${type})[]`;
+ const optionalQuestionMark = f.required ? '' : '?';
+ return `"${f.name}"${optionalQuestionMark} : ${type}`;
+ });
+
+ return {
+ code: `${EXPORT_TYPE} ${typeName} = { ${fields.join('\n')} }`,
+ typeName,
+ collectionName
+ };
+}
+
+// Needed for `@/pocketbase/zod-schema`
+
+function createCollectionZodRawType(model: AnyCollectionModel): GeneratedCollectionTypeData {
+ const collectionName = model.name;
+ const typeName = capitalize(camelCase(model.name)) + ZOD_RAW_SHAPE;
+
+ const fields = model.schema.map((f) => {
+ let type: string;
+ if (f.type == 'number') type = 'z.ZodNumber';
+ else if (f.type == 'bool') type = 'z.ZodBoolean';
+ else if (f.type == 'date') type = 'z.ZodString';
+ else if (f.type == 'editor') type = 'z.ZodString';
+ else if (f.type == 'email') type = 'z.ZodString';
+ else if (f.type == 'file') type = 'z.ZodType';
+ else if (f.type == 'json') type = 'z.ZodUnknown';
+ else if (f.type == 'relation') type = 'z.ZodString';
+ else if (f.type == 'select') type = `z.ZodEnum<${JSON.stringify(f.options.values)}>`;
+ else if (f.type == 'text') type = 'z.ZodString';
+ else if (f.type == 'url') type = 'z.ZodString';
+ else throw new UnhandledFieldTypeError();
+ if (isArrayField(f)) type = `z.ZodArray<${type}>`;
+ if (!f.required) type = `z.ZodOptional<${type}>`;
+ return `"${f.name}" : ${type}`;
+ });
+
+ return {
+ code: `${EXPORT_TYPE} ${typeName} = { ${fields.join('\n')} }`,
+ typeName,
+ collectionName
+ };
+}
+
+// Needed for `@/collections-components`
+
+function createCollectionExpand(model: AnyCollectionModel): GeneratedCollectionTypeData {
+ const collectionName = model.name;
+ const typeName = capitalize(camelCase(model.name)) + EXPAND;
+
+ const expands = [
+ ...createCollectionExpandItems(model),
+ ...createCollectionInverseExpandItems(model)
+ ];
+
+ const expandCode = expands.length == 0 ? RECORD_NEVER : `{ ${expands.join('\n')} }`;
+
+ return {
+ code: `${EXPORT_TYPE} ${typeName} = ${expandCode}`,
+ typeName,
+ collectionName
+ };
+}
+
+function createCollectionExpandItems(model: AnyCollectionModel): string[] {
+ return model.schema
+ .filter((field) => field.type == 'relation')
+ .map((field) => {
+ const model = CollectionsModels.find((m) => m.id == field.options.collectionId);
+ assert(model, 'Missing model');
+ const optionalQuestionMark = field.required ? '' : '?';
+ const optionalArray = field.options.maxSelect == 1 ? '' : '[]';
+ return `${field.name}${optionalQuestionMark} : (${COLLECTION_RESPONSES}["${model.name}"])${optionalArray}`;
+ });
+}
+
+function createCollectionInverseExpandItems(model: AnyCollectionModel): string[] {
+ function isInverseRelationField(field: AnySchemaField): field is RelationSchemaField {
+ return field.type == 'relation' && field.options.collectionId == model.id;
+ }
+
+ const inverseRelatedCollections = CollectionsModels.filter((c) =>
+ c.schema.some(isInverseRelationField)
+ );
+
+ return inverseRelatedCollections.flatMap((c) =>
+ c.schema
+ .filter(isInverseRelationField)
+ .map((f) => `${c.name}_via_${f.name}?: ${COLLECTION_RESPONSES}["${c.name}"][]`)
+ );
+}
+
+//
+
+function createCollectionRelatedCollections(
+ model: AnyCollectionModel
+): GeneratedCollectionTypeData {
+ const collectionName = model.name;
+ const typeName = capitalize(camelCase(model.name)) + RELATED_COLLECTIONS;
+
+ const relatedCollections = model.schema
+ .filter((field) => field.type == 'relation')
+ .map((field) => {
+ const options = field.options;
+ const model = CollectionsModels.find((m) => m.id == options.collectionId);
+ assert(model, 'missing model');
+ return `${field.name} : "${model.name}"`;
+ });
+
+ const relatedType =
+ relatedCollections.length == 0 ? RECORD_NEVER : `{ ${relatedCollections.join('\n')} }`;
+
+ return {
+ code: `${EXPORT_TYPE} ${typeName} = ${relatedType}`,
+ typeName,
+ collectionName
+ };
+}
+
+//
+
+function createIndexType(data: GeneratedCollectionTypeData[], category: string, addPlural = false) {
+ const entries = data.map((d) => `${d.collectionName} : ${d.typeName}`);
+ const s = addPlural ? 's' : '';
+ return `${EXPORT_TYPE} ${COLLECTION}${category}${s} = { ${entries.join('\n')} }`;
+}
+
+//
+
+class UnhandledFieldTypeError extends Error {}
+
+type GeneratedCollectionTypeData = {
+ code: string;
+ collectionName: string;
+ typeName: string;
+};
diff --git a/webapp/src/modules/pocketbase/types/index.ts b/webapp/src/modules/pocketbase/types/index.ts
new file mode 100644
index 00000000..34c16e29
--- /dev/null
+++ b/webapp/src/modules/pocketbase/types/index.ts
@@ -0,0 +1,2 @@
+export * from './index.generated';
+export * from './extra.generated';
diff --git a/webapp/src/modules/pocketbase/utils.ts b/webapp/src/modules/pocketbase/utils.ts
new file mode 100644
index 00000000..541c66e5
--- /dev/null
+++ b/webapp/src/modules/pocketbase/utils.ts
@@ -0,0 +1,5 @@
+import type { UsersResponse } from '@/pocketbase/types';
+
+export function getUserDisplayName(user: UsersResponse) {
+ return user.name ? user.name : user.username ? user.username : user.email;
+}
diff --git a/webapp/src/modules/pocketbase/zod-schema/config.ts b/webapp/src/modules/pocketbase/zod-schema/config.ts
new file mode 100644
index 00000000..d5a5c791
--- /dev/null
+++ b/webapp/src/modules/pocketbase/zod-schema/config.ts
@@ -0,0 +1,129 @@
+import { pipe } from 'effect';
+import z from 'zod';
+import type { SchemaFields } from '@/pocketbase/collections-models';
+import { getJsonDataSize } from '@/utils/other';
+import { isBefore, isAfter, isValid, parseISO } from 'date-fns';
+import { m } from '@/i18n';
+import { zodFileSchema } from '@/utils/files';
+
+/* Field Config -> Zod Type */
+
+type SchemaFieldToZodTypeMap = {
+ [Type in keyof SchemaFields]: (fieldSchema: SchemaFields[Type]) => z.ZodTypeAny;
+};
+
+export const schemaFieldToZodTypeMap: SchemaFieldToZodTypeMap = {
+ text: (config) => {
+ const { max, min, pattern } = config.options;
+ let s = z.string();
+ if (max) s = s.max(max);
+ if (min) s = s.min(min);
+ if (pattern) {
+ // // Add a "|" pipe to the regex to allow for empty string (Ciscoheat suggestion)
+ // const maybeOptionalPattern = config.required ? pattern : `|${pattern}`;
+ // TODO - Check if it is needed still
+ s = s.regex(new RegExp(pattern), m.Value_does_not_match_regex_pattern({ pattern }));
+ }
+ return s;
+ },
+
+ bool: () => {
+ return z.boolean();
+ },
+
+ email: ({ options }) => {
+ const { exceptDomains, onlyDomains } = options;
+ return pipe(z.string().email(), (zodEmail) =>
+ validateDomains(zodEmail, exceptDomains, onlyDomains)
+ );
+ },
+
+ file: ({ options }) => {
+ const { mimeTypes, maxSize } = options;
+ const mimes = mimeTypes as string[] | undefined;
+ return zodFileSchema({ mimeTypes: mimes, maxSize });
+ },
+
+ date: ({ options }) => {
+ const { min, max } = options;
+ return z
+ .string()
+ .refine(
+ (string) => isValid(parseISO(string)),
+ (value) => ({ message: `${value} is not a ISO date string` })
+ )
+ .refine(
+ (date) => (min ? isAfter(date, min) : true),
+ (value) => ({ message: `${value} is before ${min}` })
+ )
+ .refine(
+ (date) => (max ? isBefore(date, max) : true),
+ (value) => ({ message: `${value} is after ${max}` })
+ );
+ },
+
+ json: ({ options }) => {
+ const { maxSize } = options;
+ return z.unknown().refine((json) => {
+ if (maxSize) return getJsonDataSize(json) < maxSize;
+ else return true;
+ }, `Json size is bigger than ${maxSize} bytes`);
+ },
+
+ relation: () => {
+ return z.string();
+ },
+
+ number: ({ options }) => {
+ const { min, max, noDecimal } = options;
+ let s = z.number();
+ if (min) s = s.min(min);
+ if (max) s = s.max(max);
+ if (noDecimal) s = s.int();
+ return s;
+ },
+
+ select: ({ options }) => {
+ const { values } = options;
+ if (!values) throw new SelectSchemaFieldNoOptionsError();
+ return z.string().refine((s) => values.includes(s));
+ },
+
+ editor: () => {
+ return z.string();
+ },
+
+ url: ({ options }) => {
+ const { exceptDomains, onlyDomains } = options;
+ return pipe(z.string().url(), (zodUrl) => validateDomains(zodUrl, exceptDomains, onlyDomains));
+ }
+};
+
+class SelectSchemaFieldNoOptionsError extends Error {}
+
+//
+
+function validateDomains(
+ zodString: z.ZodString,
+ exceptDomains: readonly string[] | undefined = undefined,
+ onlyDomains: readonly string[] | undefined = undefined
+) {
+ let s: z.ZodString | z.ZodEffects | z.ZodEffects> =
+ zodString;
+
+ if (onlyDomains?.length) {
+ s = s.refine(
+ (string) => onlyDomains.some((domain) => string.includes(domain)),
+ m.URL_is_not_in_allowed_domains_list() + ': ' + (onlyDomains ?? []).join(', ')
+ );
+ }
+
+ if (exceptDomains?.length) {
+ s = s.refine(
+ (string) => exceptDomains.every((domain) => !string.includes(domain)),
+ m.URL_is_in_forbidden_domains_list() + ': ' + (exceptDomains ?? []).join(', ')
+ );
+ }
+
+ return s;
+}
diff --git a/webapp/src/modules/pocketbase/zod-schema/index.test.ts b/webapp/src/modules/pocketbase/zod-schema/index.test.ts
new file mode 100644
index 00000000..b17c8525
--- /dev/null
+++ b/webapp/src/modules/pocketbase/zod-schema/index.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect } from 'vitest';
+import { createCollectionZodSchema } from '.';
+import type { CollectionFormData } from '@/pocketbase/types';
+import { getCollectionModel } from '@/pocketbase/collections-models';
+import { subYears, addYears, differenceInMilliseconds, addMilliseconds } from 'date-fns';
+
+//
+
+describe('generated collection zod schema', () => {
+ const schema = createCollectionZodSchema('z_test_collection');
+
+ it('fails the validation for empty object ', () => {
+ const parseResult = schema.safeParse({});
+ expect(parseResult.success).toBe(false);
+ });
+
+ const baseData: CollectionFormData['z_test_collection'] = {
+ number_field: 3,
+ relation_field: 'generic-id',
+ text_field: 'sampletext',
+ relation_multi_field: ['id-1', 'id-2'],
+ richtext_field: '
',
+ file_field: dummyFile()
+ };
+
+ it('passes the validation for typed object', () => {
+ const parseResult = schema.safeParse(baseData);
+ expect(parseResult.success).toBe(true);
+ });
+
+ it('fails the validation for file with bad mimeType', () => {
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ file_field: dummyFile('text/json')
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ expect(parseResult.error?.issues.length).toBe(1);
+ const parseErrorPath = parseResult.error?.issues.at(0)?.path.at(0);
+ expect(parseErrorPath).toBe('file_field');
+ });
+
+ it('accepts empty string for optional url', () => {
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ url_field: ''
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(true);
+ });
+
+ it('doesn`t accept url with bad domain', () => {
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ url_field: 'https://miao.com'
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ const parseErrorPath = parseResult.error?.issues.at(0)?.path.at(0);
+ expect(parseErrorPath).toBe('url_field');
+ });
+
+ it('fails the regex test', () => {
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ text_with_regex: 'abc 123-24'
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ });
+
+ // JSON Field Checks
+
+ const jsonField = getCollectionModel('z_test_collection').schema.find(
+ (schemaField) => schemaField.type == 'json'
+ );
+ if (!jsonField) throw new Error('field not found');
+ const { maxSize: jsonMaxSize } = jsonField.options;
+ if (!jsonMaxSize) throw new Error('missing json max size');
+
+ it('fails the json size check with a large JSON object', () => {
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ json_field: generateLargeJSONObject(jsonMaxSize * 1.5)
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ const parseErrorPath = parseResult.error?.issues.at(0)?.path.at(0);
+ expect(parseErrorPath).toBe('json_field');
+ });
+
+ it('passes the json size check with a small JSON object', () => {
+ const jsonMaxSize = getCollectionModel('z_test_collection').schema[12].options.maxSize;
+ const jsonObject = generateLargeJSONObject(jsonMaxSize * 0.5);
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ json_field: jsonObject
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(true);
+ });
+
+ // Date checks
+
+ const dateField = getCollectionModel('z_test_collection').schema.find(
+ (schemaField) => schemaField.type == 'date'
+ );
+ if (!dateField) throw new Error('field not found');
+ const { max: maxDate, min: minDate } = dateField.options;
+ if (!maxDate || !minDate) throw new Error('missing min and max date');
+
+ it('fails the date check with a date earlier than minimum', () => {
+ const earlierDate = subYears(minDate, 10);
+
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ date_field: earlierDate.toISOString()
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ });
+
+ it('fails the date check with a date later than maximum', () => {
+ const laterDate = addYears(maxDate, 10);
+
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ date_field: laterDate.toISOString()
+ };
+ const parseResult = schema.safeParse(data);
+ expect(parseResult.success).toBe(false);
+ });
+
+ it('passes the date check with a date in between', () => {
+ const difference = differenceInMilliseconds(maxDate, minDate);
+ const betweenDate = addMilliseconds(minDate, difference / 2);
+
+ console.log(minDate, betweenDate.toISOString(), maxDate);
+
+ const data: CollectionFormData['z_test_collection'] = {
+ ...baseData,
+ date_field: betweenDate.toISOString()
+ };
+ const parseResult = schema.safeParse(data);
+ console.log(parseResult.error?.issues);
+ expect(parseResult.success).toBe(true);
+ });
+});
+
+function dummyFile(mime = 'text/plain') {
+ return new File(['Hello, World!'], 'hello.txt', {
+ type: mime,
+ lastModified: Date.now()
+ });
+}
+
+function generateLargeJSONObject(size = 200000) {
+ return {
+ value: 'x'.repeat(Math.floor(size))
+ };
+}
diff --git a/webapp/src/modules/pocketbase/zod-schema/index.ts b/webapp/src/modules/pocketbase/zod-schema/index.ts
new file mode 100644
index 00000000..07357e3d
--- /dev/null
+++ b/webapp/src/modules/pocketbase/zod-schema/index.ts
@@ -0,0 +1,66 @@
+import z from 'zod';
+import { pipe } from 'effect';
+import { schemaFieldToZodTypeMap } from './config';
+import {
+ getCollectionModel,
+ isArrayField,
+ type AnySchemaField,
+ type CollectionName
+} from '@/pocketbase/collections-models';
+import type { CollectionZodRawShapes } from '../types';
+
+//
+
+export type CollectionZodSchema = z.ZodObject;
+
+export function createCollectionZodSchema(
+ collection: C
+): CollectionZodSchema {
+ const { schema } = getCollectionModel(collection);
+ const schemaFields = schema as AnySchemaField[];
+
+ const entries = schemaFields.map((fieldConfig) => {
+ const zodTypeConstructor = schemaFieldToZodTypeMap[fieldConfig.type] as (
+ c: AnySchemaField
+ ) => z.ZodTypeAny;
+
+ const zodType = pipe(
+ zodTypeConstructor(fieldConfig),
+
+ // Array type handling
+ (zodType) => {
+ if (isArrayField(fieldConfig)) {
+ let s = z.array(zodType);
+ const { minSelect, maxSelect } = z
+ .object({
+ minSelect: z.number().nullish(),
+ maxSelect: z.number().nullish()
+ })
+ .parse(fieldConfig.options);
+ if (minSelect) s = s.min(minSelect);
+ if (maxSelect) s = s.max(maxSelect);
+ return s;
+ } else {
+ return zodType;
+ }
+ },
+
+ // Optional type handling
+ (zodType) => {
+ if (fieldConfig.required) {
+ if (zodType instanceof z.ZodArray) return zodType.nonempty();
+ else return zodType;
+ } else {
+ // Extra check for url: https://github.com/colinhacks/zod/discussions/1254
+ if (fieldConfig.type == 'url') return zodType.or(z.literal('')).optional();
+ else return zodType.optional();
+ }
+ }
+ );
+
+ return [fieldConfig.name, zodType];
+ });
+
+ const rawObject = Object.fromEntries(entries);
+ return z.object(rawObject) as CollectionZodSchema;
+}
diff --git a/webapp/src/lib/utils/clientFileDownload.ts b/webapp/src/modules/utils/clientFileDownload.ts
similarity index 89%
rename from webapp/src/lib/utils/clientFileDownload.ts
rename to webapp/src/modules/utils/clientFileDownload.ts
index be1a88df..60819a8c 100644
--- a/webapp/src/lib/utils/clientFileDownload.ts
+++ b/webapp/src/modules/utils/clientFileDownload.ts
@@ -1,7 +1,3 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
export function downloadFileFromUrl(url: string, fileName: string) {
const a = document.createElement('a');
a.href = url;
diff --git a/webapp/src/modules/utils/codegen.ts b/webapp/src/modules/utils/codegen.ts
new file mode 100644
index 00000000..09413917
--- /dev/null
+++ b/webapp/src/modules/utils/codegen.ts
@@ -0,0 +1,41 @@
+import type { TypedPocketBase } from '@/pocketbase/types';
+import PocketBase from 'pocketbase';
+import 'dotenv/config';
+import assert from 'node:assert';
+import prettier from 'prettier';
+
+//
+
+export const GENERATED = 'generated';
+export const EXPORT_TYPE = 'export type ';
+export const SEPARATOR = '/* ------------------ */';
+
+//
+
+export async function initAdminPocketbase() {
+ const { PB_ADMIN_USER, PB_ADMIN_PASS, PUBLIC_POCKETBASE_URL } = process.env;
+ assert(PB_ADMIN_USER);
+ assert(PB_ADMIN_PASS);
+ assert(PUBLIC_POCKETBASE_URL);
+
+ const pb = new PocketBase(PUBLIC_POCKETBASE_URL) as TypedPocketBase;
+ await pb.admins.authWithPassword(PB_ADMIN_USER, PB_ADMIN_PASS);
+ return pb;
+}
+
+export async function formatCode(
+ code: string,
+ options: prettier.Options = { parser: 'typescript' }
+) {
+ const formatOptions = await prettier.resolveConfig(import.meta.url, { editorconfig: true });
+ const formattedCode = await prettier.format(code, {
+ ...formatOptions,
+ ...options
+ });
+ return formattedCode;
+}
+
+export function logCodegenResult(subject: string, filePath: string) {
+ console.log('');
+ console.log(`📦 Generated ${subject} in: ${filePath}`);
+}
diff --git a/webapp/src/modules/utils/errors.ts b/webapp/src/modules/utils/errors.ts
new file mode 100644
index 00000000..f26f442b
--- /dev/null
+++ b/webapp/src/modules/utils/errors.ts
@@ -0,0 +1,11 @@
+export function getExceptionMessage(e: unknown): string {
+ if (e instanceof Error) {
+ return e.message;
+ } else {
+ return JSON.stringify(e);
+ }
+}
+
+//
+
+export class NotBrowserError extends Error {}
diff --git a/webapp/src/lib/utils/files.ts b/webapp/src/modules/utils/files.ts
similarity index 58%
rename from webapp/src/lib/utils/files.ts
rename to webapp/src/modules/utils/files.ts
index 1aeefa19..5aa3d2fa 100644
--- a/webapp/src/lib/utils/files.ts
+++ b/webapp/src/modules/utils/files.ts
@@ -1,6 +1,21 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
+import z from 'zod';
+
+//
+
+export function zodFileSchema(options: { mimeTypes?: string[]; maxSize?: number } = {}) {
+ const { mimeTypes, maxSize } = options;
+ let s = z.instanceof(File);
+ if (mimeTypes && mimeTypes.length > 0) {
+ const mimes = mimeTypes as readonly string[];
+ s = s.refine((file) => mimes.includes(file.type), `File type not: ${mimes.join(', ')}`);
+ }
+ if (maxSize) {
+ s.refine((file) => file.size < maxSize, `File size bigger than ${maxSize} bytes`);
+ }
+ return s;
+}
+
//
-// SPDX-License-Identifier: AGPL-3.0-or-later
export function readFileAsBase64(file: File): Promise {
return new Promise((resolve, reject) => {
diff --git a/webapp/src/modules/utils/fs.ts b/webapp/src/modules/utils/fs.ts
new file mode 100644
index 00000000..5495aa8d
--- /dev/null
+++ b/webapp/src/modules/utils/fs.ts
@@ -0,0 +1,8 @@
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+export function getCurrentWorkingDirectory(fileUrl: string) {
+ const __filename = fileURLToPath(fileUrl);
+ const __dirname = path.dirname(__filename);
+ return __dirname;
+}
diff --git a/webapp/src/modules/utils/other.ts b/webapp/src/modules/utils/other.ts
new file mode 100644
index 00000000..3cb921c5
--- /dev/null
+++ b/webapp/src/modules/utils/other.ts
@@ -0,0 +1,70 @@
+import { dev } from '$app/environment';
+import { resolveRoute } from '@/i18n';
+import type { Page } from '@sveltejs/kit';
+
+//
+
+export function getJsonDataSize(data: unknown): number {
+ return new Blob([JSON.stringify(data)]).size;
+}
+
+export function capitalize(text: string) {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+export function createDummyFile(options: { filename?: string; size?: number; mime?: string } = {}) {
+ const { filename = 'file.txt', size = 10, mime = 'text/plain' } = options;
+ return new File(['a'.repeat(size)], filename, {
+ type: mime,
+ lastModified: Date.now()
+ });
+}
+
+//
+
+export function isLinkActive(href: string, page: Page, includeSubpages = false) {
+ const isExact = page.url.pathname == resolveRoute(href, page.url);
+ const isParent = page.url.pathname.includes(href);
+ return includeSubpages ? isParent : isExact;
+}
+
+//
+
+export function removeTrailingSlash(text: string) {
+ if (text.endsWith('/')) {
+ return text.slice(0, -1);
+ }
+ return text;
+}
+
+//
+
+export function wait(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+//
+
+export type MaybeArray = T | T[] | undefined | null;
+
+export function ensureArray(data: MaybeArray): T[] {
+ if (Array.isArray(data)) return data;
+ if (data) return [data];
+ else return [];
+}
+
+export function maybeArrayIsValue(data: MaybeArray): boolean {
+ if (Array.isArray(data)) return Boolean(data.length);
+ else return Boolean(data);
+}
+
+//
+
+export function log(...data: unknown[]) {
+ if (dev) console.log(...data);
+}
+
+export function pipeLog(data: T): T {
+ log(data);
+ return data;
+}
diff --git a/webapp/src/lib/utils/sessionStorage.ts b/webapp/src/modules/utils/sessionStorage.ts
similarity index 100%
rename from webapp/src/lib/utils/sessionStorage.ts
rename to webapp/src/modules/utils/sessionStorage.ts
diff --git a/webapp/src/modules/utils/svelte-context.ts b/webapp/src/modules/utils/svelte-context.ts
new file mode 100644
index 00000000..5a5774cb
--- /dev/null
+++ b/webapp/src/modules/utils/svelte-context.ts
@@ -0,0 +1,26 @@
+import { getContext as svelteGetContext, setContext as svelteSetContext } from 'svelte';
+
+//
+
+export function setupDerivedContext(key: string) {
+ const contextKey = Symbol(key);
+
+ function setDerivedContext(derived: () => CustomContext) {
+ return svelteSetContext(contextKey, derived);
+ }
+
+ /**
+ * @returns a value that can be `$derived(...)` in order to get a reactive context
+ */
+ function getDerivedContext() {
+ const baseContext =
+ svelteGetContext>>(contextKey);
+ return baseContext();
+ }
+
+ return {
+ setDerivedContext,
+ getDerivedContext,
+ contextKey
+ };
+}
diff --git a/webapp/src/modules/utils/types.ts b/webapp/src/modules/utils/types.ts
new file mode 100644
index 00000000..470bd246
--- /dev/null
+++ b/webapp/src/modules/utils/types.ts
@@ -0,0 +1,23 @@
+// Base types
+
+export type GenericRecord = Record;
+
+export type MaybePromise = T | Promise;
+
+// Logic operations
+
+export type If = Condition extends true
+ ? IfTrue
+ : IfFalse;
+
+export type Not = Condition extends true ? false : true;
+
+//
+
+export type IsArray = T extends Array ? true : false;
+
+export type KeyOf = Extract;
+
+export type ValueOf = T[keyof T];
+
+export type InferArrayType = T extends (infer U)[] ? U : T;
diff --git a/webapp/src/lib/webauthn/index.ts b/webapp/src/modules/webauthn/index.ts
similarity index 80%
rename from webapp/src/lib/webauthn/index.ts
rename to webapp/src/modules/webauthn/index.ts
index c38eda8d..5e209c4e 100644
--- a/webapp/src/lib/webauthn/index.ts
+++ b/webapp/src/modules/webauthn/index.ts
@@ -1,10 +1,7 @@
-// SPDX-FileCopyrightText: 2024 The Forkbomb Company
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
+import { pb } from '@/pocketbase';
+import { log } from '@/utils/other';
-import { pb } from '$lib/pocketbase';
-import { bufferDecode, bufferEncode } from '$lib/utils/buffer';
-import { log } from '$lib/utils/devLog';
+//
export async function registerUser(username: string, description = '') {
const credentialCreationOptions = await pb.send('/api/webauthn/register/begin/' + username, {});
@@ -48,8 +45,10 @@ export async function loginUser(username: string) {
credentialRequestOptions.publicKey.challenge = bufferDecode(
credentialRequestOptions.publicKey.challenge
);
- credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem: any) {
- listItem.id = bufferDecode(listItem.id);
+ credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem: {
+ id: string | Uint8Array;
+ }) {
+ listItem.id = bufferDecode(listItem.id as string);
});
const credential = await navigator.credentials.get({
publicKey: credentialRequestOptions.publicKey
@@ -93,3 +92,20 @@ export async function isPlatformAuthenticatorAvailable() {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
else return false;
}
+
+/* Utils */
+
+// Base64 to ArrayBuffer
+function bufferDecode(value: string) {
+ return Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+}
+
+function bufferEncode(buffer: ArrayBuffer) {
+ let binary = '';
+ const bytes = new Uint8Array(buffer);
+ const len = bytes.byteLength;
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+}
diff --git a/webapp/src/routes/(nru)/+layout.svelte b/webapp/src/routes/(nru)/+layout.svelte
new file mode 100644
index 00000000..31c373b8
--- /dev/null
+++ b/webapp/src/routes/(nru)/+layout.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {#snippet topbarRight()}
+
+ {/snippet}
+
+ {@render children?.()}
+
diff --git a/webapp/src/routes/(nru)/+layout.ts b/webapp/src/routes/(nru)/+layout.ts
new file mode 100644
index 00000000..52326a33
--- /dev/null
+++ b/webapp/src/routes/(nru)/+layout.ts
@@ -0,0 +1,9 @@
+import { error } from '@sveltejs/kit';
+import { loadFeatureFlags } from '@/features';
+import { verifyUser } from '@/auth/verifyUser';
+import { redirect } from '@/i18n';
+
+export const load = async ({ url, fetch }) => {
+ if (!(await loadFeatureFlags(fetch)).AUTH) error(404);
+ if (await verifyUser()) redirect('/my', url);
+};
diff --git a/webapp/src/routes/(nru)/forgot-password/+page.svelte b/webapp/src/routes/(nru)/forgot-password/+page.svelte
new file mode 100644
index 00000000..4d518505
--- /dev/null
+++ b/webapp/src/routes/(nru)/forgot-password/+page.svelte
@@ -0,0 +1,30 @@
+
+
+{#if !form}
+
+{:else if form.success}
+
+ Reset email sent successfully!
+ Please click the link in the email to reset your password.
+
+{/if}
diff --git a/webapp/src/routes/(nru)/login/+layout.svelte b/webapp/src/routes/(nru)/login/+layout.svelte
new file mode 100644
index 00000000..750798b3
--- /dev/null
+++ b/webapp/src/routes/(nru)/login/+layout.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+Log in
+
+{#if $featureFlags.WEBAUTHN}
+
+
{m.Choose_your_authentication_method()}
+
+ {#each modes as { href, title }}
+ {@const isActive = $page.url.pathname === href}
+
+ {title}
+
+ {/each}
+
+
+{/if}
+
+
+ {@render children?.()}
+
+
+
diff --git a/webapp/src/routes/(nru)/login/+page.svelte b/webapp/src/routes/(nru)/login/+page.svelte
new file mode 100644
index 00000000..952169fe
--- /dev/null
+++ b/webapp/src/routes/(nru)/login/+page.svelte
@@ -0,0 +1,60 @@
+
+
+
diff --git a/webapp/src/routes/(nru)/login/webauthn/+page.svelte b/webapp/src/routes/(nru)/login/webauthn/+page.svelte
new file mode 100644
index 00000000..85369b99
--- /dev/null
+++ b/webapp/src/routes/(nru)/login/webauthn/+page.svelte
@@ -0,0 +1,50 @@
+
+
+
diff --git a/webapp/src/routes/(nru)/login/webauthn/+page.ts b/webapp/src/routes/(nru)/login/webauthn/+page.ts
new file mode 100644
index 00000000..7a8be50b
--- /dev/null
+++ b/webapp/src/routes/(nru)/login/webauthn/+page.ts
@@ -0,0 +1,9 @@
+import { loadFeatureFlags } from '@/features';
+import { error } from '@sveltejs/kit';
+
+export const load = async ({ fetch }) => {
+ const { WEBAUTHN } = await loadFeatureFlags(fetch);
+ if (!WEBAUTHN) {
+ error(404);
+ }
+};
diff --git a/webapp/src/routes/[[lang]]/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts b/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts
similarity index 100%
rename from webapp/src/routes/[[lang]]/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts
rename to webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts
diff --git a/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts.rej b/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts.rej
new file mode 100644
index 00000000..9f7fe949
--- /dev/null
+++ b/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts.rej
@@ -0,0 +1,19 @@
+diff a/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts b/webapp/src/routes/(nru)/organization-invite-[orgId]-[inviteId]-[email]-[[userId]]/+page.ts (rejected hunks)
+@@ -1,11 +1,11 @@
+-import { loadFeatureFlags } from '$lib/features/index.js';
+-import { redirect } from '$lib/i18n/index.js';
+-import { OrganizationInviteSession } from '$lib/organizations/invites';
+-import { pb } from '$lib/pocketbase/index.js';
++import { loadFeatureFlags } from '@/features/index.js';
++import { redirect } from '@/i18n/index.js';
++import { OrganizationInviteSession } from '@/organizations/invites';
++import { pb } from '@/pocketbase';
+ import { error } from '@sveltejs/kit';
+
+-export const load = async ({ params, url }) => {
+- const featureFlags = await loadFeatureFlags();
++export const load = async ({ params, url, fetch }) => {
++ const featureFlags = await loadFeatureFlags(fetch);
+ if (!featureFlags.ORGANIZATIONS) error(404);
+
+ OrganizationInviteSession.start({
diff --git a/webapp/src/routes/[[lang]]/(nru)/register/+page.svelte b/webapp/src/routes/(nru)/register/+page.svelte
similarity index 51%
rename from webapp/src/routes/[[lang]]/(nru)/register/+page.svelte
rename to webapp/src/routes/(nru)/register/+page.svelte
index 78fce906..7dc97076 100644
--- a/webapp/src/routes/[[lang]]/(nru)/register/+page.svelte
+++ b/webapp/src/routes/(nru)/register/+page.svelte
@@ -1,21 +1,18 @@
-
+ import { Form, createForm } from '@/forms';
+ import { Field, CheckboxField } from '@/forms/fields';
+ import { zod } from 'sveltekit-superforms/adapters';
-
{#if $featureFlags.ORGANIZATIONS}
@@ -59,24 +59,24 @@ SPDX-License-Identifier: AGPL-3.0-or-later
{#await getOrganization(inviteSession.organizationId) then organization}
-
+
{@html m.you_have_been_invited_by_organization_to_join_the_platform({
organizationName: organization.name
})}
-
-
{m.Please_register_using_the_provided_email_account_()}
+
+
{m.Please_register_using_the_provided_email_account_()}
{/await}
{/if}
{/if}
-Create an account
+Create an account
-
diff --git a/webapp/src/routes/(nru)/reset-password/+page.svelte b/webapp/src/routes/(nru)/reset-password/+page.svelte
new file mode 100644
index 00000000..66a8da2f
--- /dev/null
+++ b/webapp/src/routes/(nru)/reset-password/+page.svelte
@@ -0,0 +1,39 @@
+
+
+{#if !form}
+
+{:else if form.success}
+
+ {m.Password_reset_successfully()}
+ {m.Please_click_the_link_in_the_email_to_reset_your_password_()}
+
+{/if}
diff --git a/webapp/src/routes/+error.svelte b/webapp/src/routes/+error.svelte
index e7dfb05f..4f37af4a 100644
--- a/webapp/src/routes/+error.svelte
+++ b/webapp/src/routes/+error.svelte
@@ -48,11 +48,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
-
{data.status} {data.title}
-
{data.description}
+
{data.status} {data.title}
+
{data.description}
{#if status !== 503}
-
Here are some Helpful link:
+
Here are some Helpful link:
+-
++
+
+-
+-
++
++
+
+
+
+@@ -416,4 +377,4 @@
+
+
+
+-
++
-->
diff --git a/webapp/src/routes/[[lang]]/+page.ts b/webapp/src/routes/+page.ts
similarity index 100%
rename from webapp/src/routes/[[lang]]/+page.ts
rename to webapp/src/routes/+page.ts
diff --git a/webapp/src/routes/+page.ts.rej b/webapp/src/routes/+page.ts.rej
new file mode 100644
index 00000000..54b5fdce
--- /dev/null
+++ b/webapp/src/routes/+page.ts.rej
@@ -0,0 +1,9 @@
+diff a/webapp/src/routes/+page.ts b/webapp/src/routes/+page.ts (rejected hunks)
+@@ -1,5 +1,5 @@
+-import { redirect } from '$lib/i18n';
+-import { verifyUser } from '$lib/auth/verifyUser';
++import { redirect } from '@/i18n';
++import { verifyUser } from '@/auth/verifyUser';
+
+ export const load = async ({ url }) => {
+ if (await verifyUser()) redirect('/my', url);
diff --git a/webapp/src/routes/[[lang]]/my/organizations/+page.svelte b/webapp/src/routes/[[lang]]/my/organizations/+page.svelte
deleted file mode 100644
index 9af40273..00000000
--- a/webapp/src/routes/[[lang]]/my/organizations/+page.svelte
+++ /dev/null
@@ -1,211 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {#if records.length > 0}
-
-
- {#each records as record}
-
- {record.expand?.organization.name}
-
- updateInvite(record.id, 'accept')}>
- {m.accept_invite()}
-
- updateInvite(record.id, 'decline')}>
- {m.decline_invite()}
-
-
-
- {/each}
-
- {/if}
-
-
-
- {#if records.length}
-
-
-
-
- {#each records as request}
- {@const organization = request.expand?.organization}
- {#if organization}
- {@const avatarUrl = pb.files.getUrl(organization, organization.avatar)}
-
-
-
-
-
{request.expand?.organization.name}
-
{m.Pending()}
-
-
- {
- deleteJoinRequest(request.id);
- }}
- >
- {m.Undo_request()}
-
-
-
- {/if}
- {/each}
-
-
- {/if}
-
-
-
-
-
-
- {m.Join_an_organization()}
-
-
-
- {m.Create_a_new_organization()}
-
-
-
-
-
-
-
-
-
-
- {#if records.length > 0}
-
- {#each records as a}
- {@const org = a.expand?.organization}
- {@const role = a.expand?.role}
- {#if org && role}
-
-
-
- {org.name}
-
- {#if role.name == ADMIN || role.name == OWNER}
-
{c(role.name)}
- {/if}
-
- {#if org.description}
- {org.description}
- {/if}
-
-
- {#if role.name == OWNER}
-
- {m.Settings()}
-
-
- {/if}
-
-
- {/if}
- {/each}
-
- {/if}
-
-
-
diff --git a/webapp/src/routes/[[lang]]/my/organizations/[id]/members/+page.svelte b/webapp/src/routes/[[lang]]/my/organizations/[id]/members/+page.svelte
deleted file mode 100644
index 081847e9..00000000
--- a/webapp/src/routes/[[lang]]/my/organizations/[id]/members/+page.svelte
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {m.invite_members()}
-
-
-
-
-
-
-
-
-
-
-
- {#each records as record}
- {@const user = record.expand?.user}
- {@const role = record.expand?.role}
- {#if user && role && userRole}
-
-
-
-
- {getUserDisplayName(user)}
-
-
- {#if user.id == $currentUser?.id}
- {m.You()}
- {/if}
- {#if role.name != OrgRoles.MEMBER}
- {c(role.name)}
- {/if}
-
-
-
-
-
-
- {#if userRole.level < role.level}
-
-
- Edit role
-