From 8f539745fe9b41f7a52908ea00f131a2bc9f9c60 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 14:16:06 -0500 Subject: [PATCH] web: add @hello-pangea/dnd, wire drag-to-reorder in recommendations manager --- js/web/package.json | 3 +- js/web/src/routes/settings.tsx | 88 ++++-- .../src/routes/settings/recommendations.tsx | 259 ++++++++++-------- pnpm-lock.yaml | 69 +++++ 4 files changed, 274 insertions(+), 145 deletions(-) diff --git a/js/web/package.json b/js/web/package.json index 3289ecee6..11904c9f1 100644 --- a/js/web/package.json +++ b/js/web/package.json @@ -31,7 +31,8 @@ "react-i18next": "^15.7.3", "i18next": "^25.4.2", "i18next-fluent": "^2.0.0", - "@streamplace/i18n": "workspace:*" + "@streamplace/i18n": "workspace:*", + "@hello-pangea/dnd": "^18.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.0", diff --git a/js/web/src/routes/settings.tsx b/js/web/src/routes/settings.tsx index b7a1d67a7..e008e343d 100644 --- a/js/web/src/routes/settings.tsx +++ b/js/web/src/routes/settings.tsx @@ -3,19 +3,72 @@ import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; import { Globe, Info, Lock, Shield, User2, Video } from "lucide-react"; import { useTranslation } from "react-i18next"; +type NavItem = + | { + role: "link"; + to: string; + icon: React.ComponentType<{ className?: string }>; + labelKey: string; + } + | { role: "divider"; labelKey: string }; + const NAV_ITEMS = [ - { to: "/settings/account", icon: User2, labelKey: "account" }, - { to: "/settings/streaming", icon: Video, labelKey: "streaming" }, - { to: "/settings/privacy", icon: Shield, labelKey: "privacy-security" }, - { to: "/settings/languages", icon: Globe, labelKey: "languages" }, - { to: "/settings/advanced", icon: Lock, labelKey: "advanced" }, - { to: "/settings/about", icon: Info, labelKey: "about" }, + { role: "link", to: "/settings/account", icon: User2, labelKey: "account" }, + { + role: "link", + to: "/settings/streaming", + icon: Video, + labelKey: "streaming", + }, + { + role: "link", + to: "/settings/privacy", + icon: Shield, + labelKey: "privacy-security", + }, + { role: "divider", labelKey: "divider1" }, + { + role: "link", + to: "/settings/languages", + icon: Globe, + labelKey: "languages", + }, + { role: "link", to: "/settings/advanced", icon: Lock, labelKey: "advanced" }, + { role: "link", to: "/settings/about", icon: Info, labelKey: "about" }, ] as const; export const Route = createFileRoute("/settings")({ component: SettingsLayout, }); +function DisplayNavItem({ item }: { item: NavItem }) { + const { t } = useTranslation("settings"); + if (item.role === "divider") { + return
; + } + // item must be a link if not divider + + const Icon = item.icon; + + return ( + + + {t(item.labelKey)} + + ); +} + function SettingsLayout() { const { t } = useTranslation("settings"); @@ -29,28 +82,13 @@ function SettingsLayout() {

{t("settings-title")}

- {NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => ( - - - {t(labelKey)} - + {NAV_ITEMS.map((item) => ( + ))} -
+
-
+
diff --git a/js/web/src/routes/settings/recommendations.tsx b/js/web/src/routes/settings/recommendations.tsx index 2cb91eda5..82f5ab6c6 100644 --- a/js/web/src/routes/settings/recommendations.tsx +++ b/js/web/src/routes/settings/recommendations.tsx @@ -1,4 +1,5 @@ import { Button } from "@/components/ui/button"; +import { CardMenuSection } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -7,11 +8,16 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { + DragDropContext, + Draggable, + Droppable, + type DropResult, +} from "@hello-pangea/dnd"; import { createFileRoute } from "@tanstack/react-router"; import { - ArrowDown, - ArrowUp, Check, + GripVertical, Pencil, Plus, RefreshCw, @@ -64,13 +70,11 @@ function RecommendationsManager() { setStreamers([]); return; } - const response = await agent.com.atproto.repo.getRecord({ repo: userDID, collection: "place.stream.live.recommendations", rkey: "self", }); - const record = response.data.value as { streamers?: string[] }; setStreamers(record.streamers || []); } catch (error: any) { @@ -89,7 +93,6 @@ function RecommendationsManager() { try { if (!agent.did) throw new Error("User DID not found"); setSaving(true); - await agent.com.atproto.repo.putRecord({ repo: agent.did, collection: "place.stream.live.recommendations", @@ -99,7 +102,6 @@ function RecommendationsManager() { createdAt: new Date().toISOString(), }, }); - setStreamers(newStreamers); } catch (error: any) { console.error("Failed to save recommendations:", error); @@ -196,8 +198,12 @@ function RecommendationsManager() { setDeleteIndex(null); }; - const handleMove = async (from: number, to: number) => { - if (to < 0 || to >= streamers.length) return; + const handleDragEnd = async (result: DropResult) => { + if (!result.destination) return; + const from = result.source.index; + const to = result.destination.index; + if (from === to) return; + const newStreamers = [...streamers]; const [item] = newStreamers.splice(from, 1); newStreamers.splice(to, 0, item); @@ -246,27 +252,29 @@ function RecommendationsManager() { {/* Search bar */} {streamers.length < 8 && ( -
-
- - handleSearchChange(e.target.value)} - placeholder="Search for streamers..." - /> + +
+
+ + handleSearchChange(e.target.value)} + placeholder="Search for streamers..." + /> +
{searching && ( -
+
Searching…
)} {!searching && searchResults.length > 0 && ( -
+ <> {searchResults.map((actor) => { const alreadyAdded = streamers.includes(actor.did); return ( @@ -275,7 +283,7 @@ function RecommendationsManager() { type="button" onClick={() => !alreadyAdded && handleSelectActor(actor)} disabled={alreadyAdded} - className="flex items-center justify-between w-full px-2 py-1.5 text-left hover:bg-[var(--color-bg)] transition-colors disabled:opacity-50 rounded" + className="flex items-center justify-between w-full px-3 py-2 text-left hover:bg-[var(--color-bg)] transition-colors disabled:opacity-50" > @{actor.handle} {alreadyAdded && ( @@ -286,119 +294,132 @@ function RecommendationsManager() { ); })} -
+ )} {!searching && searchQuery.trim() && searchResults.length === 0 && ( -
+
No results found
)} -
+ )} - {/* Streamer list */} + {/* Streamer list with drag-to-reorder */} {loading ? (
Loading…
) : ( -
+ {streamers.length === 0 ? (
{t("no-recommendations-yet")}
) : ( - streamers.map((streamer, index) => ( -
- {/* Reorder buttons */} -
- - -
- - {/* Content */} - {editingIndex === index ? ( - <> - setEditValue(e.target.value)} - placeholder="did:plc:..." - autoFocus - className="flex-1" - /> - - - - ) : ( - <> - - {streamer || "(empty)"} - - - - + + + {(provided) => ( +
+ {streamers.map((streamer, index) => ( + + {(dragProvided, snapshot) => ( +
+ {/* Drag handle */} +
+ +
+ + {/* Content */} + {editingIndex === index ? ( + <> + setEditValue(e.target.value)} + placeholder="did:plc:..." + autoFocus + className="flex-1" + /> + + + + ) : ( + <> + + {streamer || "(empty)"} + + + + + )} +
+ )} +
+ ))} + {provided.placeholder} +
)} -
- )) + + )} {streamers.length < 8 && ( @@ -411,7 +432,7 @@ function RecommendationsManager() { Add DID manually )} -
+ )} {saving && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ec6e26e3..367819525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,6 +807,9 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@hello-pangea/dnd': + specifier: ^18.0.1 + version: 18.0.1(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@streamplace/core': specifier: workspace:* version: link:../core @@ -3717,6 +3720,12 @@ packages: '@hapi/hoek@11.0.7': resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + '@hello-pangea/dnd@18.0.1': + resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -6628,6 +6637,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -8122,6 +8134,9 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-color-keywords@1.0.0: resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} engines: {node: '>=4'} @@ -13085,6 +13100,9 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + random-path@0.1.2: resolution: {integrity: sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw==} @@ -13309,6 +13327,18 @@ packages: '@types/react': optional: true + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -13477,6 +13507,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -14597,6 +14630,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -20238,6 +20274,18 @@ snapshots: '@hapi/hoek@11.0.7': {} + '@hello-pangea/dnd@18.0.1(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.29.7 + css-box-model: 1.2.1 + raf-schd: 4.0.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.0)(redux@5.0.1) + redux: 5.0.1 + transitivePeerDependencies: + - '@types/react' + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -23559,6 +23607,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} '@types/wrap-ansi@3.0.0': {} @@ -25428,6 +25478,10 @@ snapshots: dependencies: uncrypto: 0.1.3 + css-box-model@1.2.1: + dependencies: + tiny-invariant: 1.3.3 + css-color-keywords@1.0.0: {} css-in-js-utils@3.1.0: @@ -32041,6 +32095,8 @@ snapshots: radix3@1.1.2: {} + raf-schd@4.0.3: {} + random-path@0.1.2: dependencies: base32-encode: 1.2.0 @@ -32374,6 +32430,15 @@ snapshots: - supports-color - utf-8-validate + react-redux@9.3.0(@types/react@19.2.14)(react@19.2.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.14.2: {} react-refresh@0.17.0: {} @@ -32565,6 +32630,8 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -33978,6 +34045,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tinyexec@0.3.2: {} tinyexec@1.0.2: {} -- 2.51.2