refactor: organize desktop vs. mobile into separate folders

This commit is contained in:
Arjun Patel
2026-04-29 08:42:56 -07:00
parent 3d9fe67936
commit 3a11a82cd3
194 changed files with 213 additions and 213 deletions
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
buildCustomVisibility,
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { useNetwork } from "@/hooks/use-networks";
import { cn, getInitials } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface StreamMembersOverlayProps {
networkId: string;
streamParticle: Particle & { type: "stream" };
isCreator: boolean;
onClose: () => void;
}
export function StreamMembersOverlay({
networkId,
streamParticle,
isCreator,
onClose,
}: StreamMembersOverlayProps) {
useSuspendPlayback(true, "stream-members");
const network = useNetwork(networkId);
const humans = network?.humans ?? [];
const creatorId = streamParticle.created_by_human_id;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const memberSet = new Set(memberIds);
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
const setNetworkWide = useCallback(() => {
void updateParticleVisibleTo(docPath, buildNetworkVisibility(networkId));
}, [docPath, networkId]);
const setCustomOnlyCreator = useCallback(() => {
void updateParticleVisibleTo(docPath, buildCustomVisibility([creatorId]));
}, [docPath, creatorId]);
const removeMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return;
void updateParticleVisibleTo(docPath, buildCustomVisibility(next));
},
[docPath, creatorId, visibility],
);
const addMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
void updateParticleVisibleTo(
docPath,
buildCustomVisibility([...visibility.humanIds, id]),
);
},
[docPath, visibility],
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
to close
</span>
</div>
{/* Visibility */}
<section className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
Visibility
</h3>
{isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<VisibilityPill
active={visibility.mode === "network"}
icon={<Globe className="size-3.5" />}
label="Network-wide"
onClick={setNetworkWide}
/>
<VisibilityPill
active={visibility.mode === "custom"}
icon={<Lock className="size-3.5" />}
label="Specific people"
onClick={setCustomOnlyCreator}
/>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-white/70">
{visibility.mode === "network" ? (
<>
<Globe className="size-3.5 text-white/40" />
<span>Everyone in {network?.name ?? "network"}</span>
</>
) : (
<>
<Lock className="size-3.5 text-white/40" />
<span>{memberIds.length} specific people</span>
</>
)}
</div>
)}
</section>
{/* Member list */}
<section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
{visibility.mode === "network" ? "Has access" : "People"}{" "}
<span className="ml-1 text-white/20">{memberIds.length}</span>
</h3>
<ScrollArea className="min-h-0 flex-1">
<ul className="flex flex-col gap-0.5 pr-2">
{memberIds.map((id) => {
const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId;
const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow;
return (
<li
key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{display.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
"flex-1 truncate",
!display.exists && "italic text-white/40",
)}
>
{display.displayName}
</span>
{isCreatorRow && (
<span className="text-[10px] uppercase tracking-wider text-white/30">
Creator
</span>
)}
{canRemove && (
<button
type="button"
onClick={() => removeMember(id)}
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
aria-label={`Remove ${display.displayName}`}
>
<X className="size-3.5" />
</button>
)}
</li>
);
})}
</ul>
</ScrollArea>
</section>
{/* Add */}
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
<section className="mt-4 border-t border-white/5 pt-4">
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
<UserPlus className="size-3" />
Add people
</h3>
<ScrollArea className="max-h-32">
<ul className="flex flex-col gap-0.5 pr-2">
{availableToAdd.map((human) => (
<li key={human.id}>
<button
type="button"
onClick={() => addMember(human.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
)}
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{getInitials(human.email)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{human.email_prefix}</span>
<UserPlus className="size-3.5 text-white/30" />
</button>
</li>
))}
</ul>
</ScrollArea>
</section>
)}
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
<p className="mt-4 text-center text-xs text-white/30">
<Users className="mr-1 inline size-3" />
Everyone in the network is already a member
</p>
)}
</div>
</div>,
document.body,
);
}
function VisibilityPill({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors",
active
? "bg-white/10 text-white/90"
: "text-white/50 hover:text-white/80",
)}
>
{icon}
{label}
</button>
);
}