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..."
- />
+
+
{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 && (
-
+
)}
- {/* 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: {}