diff --git a/apps/web-react/locales/en/common.json b/apps/web-react/locales/en/common.json index 2e4e21590..55d48b227 100644 --- a/apps/web-react/locales/en/common.json +++ b/apps/web-react/locales/en/common.json @@ -372,6 +372,8 @@ "chat.newMessages": "New messages", "chat.sidebar.title": "Chat", "chat.sidebar.noActiveChats": "No active chats", + "chat.sidebar.inactive": "Inactive", + "chat.room.scrim": "Scrim", "fc.title": "Friend code", "fc.whereToFind": "Find your friend code on your Nintendo Switch (2)", "fc.onceSetStaffOnly": "Once set, only a member of staff can change it", diff --git a/apps/web/package.json b/apps/web/package.json index 27a2285f1..bbc2c8eb0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,7 +34,8 @@ "nprogress": "^0.2.0", "remeda": "2.39.0", "sql-formatter": "^15.8.2", - "valibot": "^1.4.2" + "valibot": "^1.4.2", + "web-haptics": "0.0.6" }, "devDependencies": { "@inlang/paraglide-js": "^2.24.1", diff --git a/apps/web/src/lib/components/ConfirmDialog.svelte b/apps/web/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 000000000..ef940e968 --- /dev/null +++ b/apps/web/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,83 @@ + + + +{@render trigger({ + onclick: () => { + open = true; + }, + type: "button", +})} + +{#if open} + { + open = false; + }} + > +
+

{dialogHeading}

+ {#if description} + {description} + {/if} +
+ +
+
+
+{/if} diff --git a/apps/web/src/lib/components/Divider.svelte b/apps/web/src/lib/components/Divider.svelte new file mode 100644 index 000000000..a2a9a00b2 --- /dev/null +++ b/apps/web/src/lib/components/Divider.svelte @@ -0,0 +1,44 @@ + + +
{@render children?.()}
+ + diff --git a/apps/web/src/lib/components/LocaleTime.svelte b/apps/web/src/lib/components/LocaleTime.svelte new file mode 100644 index 000000000..ff892ecf2 --- /dev/null +++ b/apps/web/src/lib/components/LocaleTime.svelte @@ -0,0 +1,40 @@ + + + + diff --git a/apps/web/src/lib/components/NoteAvatar.svelte b/apps/web/src/lib/components/NoteAvatar.svelte new file mode 100644 index 000000000..e198b90c8 --- /dev/null +++ b/apps/web/src/lib/components/NoteAvatar.svelte @@ -0,0 +1,148 @@ + + + + + + + + {@render children()} + {#if sentiment} + + {/if} + + + diff --git a/apps/web/src/lib/components/StageImage.svelte b/apps/web/src/lib/components/StageImage.svelte new file mode 100644 index 000000000..64dc38d52 --- /dev/null +++ b/apps/web/src/lib/components/StageImage.svelte @@ -0,0 +1,31 @@ + + +{label} diff --git a/apps/web/src/lib/components/Table.svelte b/apps/web/src/lib/components/Table.svelte new file mode 100644 index 000000000..c45c903eb --- /dev/null +++ b/apps/web/src/lib/components/Table.svelte @@ -0,0 +1,58 @@ + + +
+ + {@render children()} +
+
+ + diff --git a/apps/web/src/lib/components/TimePopover.svelte b/apps/web/src/lib/components/TimePopover.svelte new file mode 100644 index 000000000..a60a9672d --- /dev/null +++ b/apps/web/src/lib/components/TimePopover.svelte @@ -0,0 +1,104 @@ + + + +
+ + {#snippet trigger(triggerProps)} + + {/snippet} +
+
+ +
+ + {#if footerText} +
+ {footerText} +
+ {/if} +
+
+
+ + diff --git a/apps/web/src/lib/components/filter-bar/FilterBar.svelte b/apps/web/src/lib/components/filter-bar/FilterBar.svelte new file mode 100644 index 000000000..85a069a79 --- /dev/null +++ b/apps/web/src/lib/components/filter-bar/FilterBar.svelte @@ -0,0 +1,297 @@ + + + +
+ {#each visiblePills as pill (pill.key)} + {@render filterPill(pill)} + {/each} + {#if hiddenPills.length > 0} + {@render addFilterMenu()} + {/if} + {#if onReset || actions} +
+ {#if onReset} + + {/if} + {#if actions}{@render actions()}{/if} +
+ {/if} +
+ +{#snippet filterPill(pill: FilterBarPill)} +
+ { + openPillKey = isOpen ? pill.key : null; + }} + popoverClass={["filterPopover", pill.popoverClass] + .filter(Boolean) + .join(" ")} + > + {#snippet trigger(triggerProps)} + + {/snippet} + {@render pill.popover()} + + {#if pill.onRemove} + + {/if} +
+{/snippet} + +{#snippet addFilterMenu()} + + {#snippet trigger(triggerProps)} +
+ +
+ {/snippet} + {#each hiddenPills as pill (pill.key)} + addPill(pill)} + testId={pill.testId ? `menu-item-${pill.testId}` : undefined} + > + {pill.name} + + {/each} +
+{/snippet} + + diff --git a/apps/web/src/lib/components/filter-bar/filter-bar-types.ts b/apps/web/src/lib/components/filter-bar/filter-bar-types.ts new file mode 100644 index 000000000..2ac915d12 --- /dev/null +++ b/apps/web/src/lib/components/filter-bar/filter-bar-types.ts @@ -0,0 +1,18 @@ +import type { Snippet } from "svelte"; + +export interface FilterBarPill { + key: string; + /** Translated filter name shown on the pill and in the add filter menu. */ + name: string; + /** Translated current value shown on the pill. Null when the filter is at its default. */ + formattedValue: string | null; + /** Popover content. Inputs inside write search params directly (instant apply). */ + popover: Snippet; + /** Resets the pill's param(s) to defaults. Renders the remove button. */ + onRemove?: () => void; + /** Writes a starting value when the pill is added from the menu. */ + onAdd?: () => void; + icon?: Snippet; + popoverClass?: string; + testId?: string; +} diff --git a/apps/web/src/lib/components/layout/Layout.svelte b/apps/web/src/lib/components/layout/Layout.svelte index 93307d327..edd4e9bd5 100644 --- a/apps/web/src/lib/components/layout/Layout.svelte +++ b/apps/web/src/lib/components/layout/Layout.svelte @@ -6,6 +6,9 @@ import Image from "#lib/components/Image.svelte"; import NotificationDot from "#lib/components/NotificationDot.svelte"; import { loggedInUser } from "#lib/features/auth/user-state.ts"; import { getPatrons } from "#lib/features/front-page/front-page.remote.ts"; +import ChatSidebar from "#lib/features/chat/components/ChatSidebar.svelte"; +import { chatUi } from "#lib/features/chat/chat-state.svelte.ts"; +import { getChatRooms } from "#lib/features/chat/chat.remote.ts"; import { getNotifications } from "#lib/features/notifications/notifications.remote.ts"; import { UnseenNotificationsDot } from "#lib/features/notifications/notifications-state.svelte.ts"; import { toNotificationRows } from "#lib/features/notifications/notifications-utils.ts"; @@ -79,9 +82,18 @@ const unseenFriendRequests = $derived( const isFrontPage = $derived(page.url.pathname === "/"); -// xxx: wire to the chat sidebar when the chat rebuild lands (svelte-big-bang -// phase 3); until then the button renders for layout parity with the React app -const noopUntilChatRebuild = () => {}; +// chat data streams lazily after hydration, like the bell +const chatRoomsQuery = $derived( + mounted.current && user ? getChatRooms() : null, +); +const chatUnreadCount = $derived( + chatRoomsQuery?.current?.rooms.reduce( + (total, room) => total + room.unseenCount, + 0, + ) ?? 0, +); + +let chatModalOpen = $state(false); const breadcrumbs = $derived(page.data.breadcrumbs ?? []); const currentPageText = $derived(breadcrumbs.at(-1)?.text); @@ -155,9 +167,10 @@ function handleScroll() { lastScrollY = currentScrollY; } -// the tablet-only sidenav modal closes on navigation +// the tablet-only modals close on navigation afterNavigate(() => { sideNavModalOpen = false; + chatModalOpen = false; }); function showModal(dialog: HTMLDialogElement) { @@ -419,9 +432,15 @@ function formatRelativeDate(timestamp: number) { showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))} showSearch={Boolean(user)} isLoggedIn={Boolean(user)} - onChatToggle={user ? noopUntilChatRebuild : undefined} - onChatModalToggle={user ? noopUntilChatRebuild : undefined} - chatUnreadCount={0} + onChatToggle={user && !chatUi.sidebarOpen + ? () => chatUi.toggleSidebar() + : undefined} + onChatModalToggle={user + ? () => { + chatModalOpen = !chatModalOpen; + } + : undefined} + {chatUnreadCount} /> @@ -429,6 +448,34 @@ function formatRelativeDate(timestamp: number) {