Skip to content

Commit

Permalink
Merge pull request #2055 from dubinc/bulk-actions-mobile
Browse files Browse the repository at this point in the history
Mobile Bulk Link Actions
  • Loading branch information
steven-tey authored Feb 20, 2025
2 parents 6aedb59 + b63d152 commit 0cbb018
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 193 deletions.
2 changes: 1 addition & 1 deletion apps/web/app/app.dub.co/(dashboard)/[slug]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function WorkspaceLinks() {
</div>
) : canCreateLinks ? (
<>
<div className="grow-0">
<div className="hidden grow-0 sm:block">
<CreateLinkButton />
</div>
<MoreLinkOptions />
Expand Down
1 change: 1 addition & 0 deletions apps/web/ui/links/link-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function LinkControls({ link }: { link: ResponseLink }) {
},
{
enabled: openPopover || (hovered && openMenuLinkId === null),
priority: 1, // Take priority over display options
},
);

Expand Down
14 changes: 13 additions & 1 deletion apps/web/ui/links/link-details-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
memo,
PropsWithChildren,
useContext,
useMemo,
Expand All @@ -33,6 +34,7 @@ import {
} from "react";
import { useShareDashboardModal } from "../modals/share-dashboard-modal";
import { LinkControls } from "./link-controls";
import { useLinkSelection } from "./link-selection-provider";
import { ResponseLink } from "./links-container";
import { LinksDisplayContext } from "./links-display-provider";
import TagBadge from "./tag-badge";
Expand Down Expand Up @@ -81,11 +83,21 @@ export function LinkDetailsColumn({ link }: { link: ResponseLink }) {
{displayProperties.includes("analytics") && (
<AnalyticsBadge link={link} />
)}
<LinkControls link={link} />
<Controls link={link} />
</div>
);
}

const Controls = memo(({ link }: { link: ResponseLink }) => {
const { isSelectMode } = useLinkSelection();

return (
<div className={cn(isSelectMode && "hidden sm:block")}>
<LinkControls link={link} />
</div>
);
});

function TagsTooltip({
additionalTags,
children,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/ui/links/link-selection-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
import { ResponseLink } from "./links-container";

interface LinkSelectionContext {
isSelectMode: boolean;
setIsSelectMode: Dispatch<SetStateAction<boolean>>;
selectedLinkIds: string[];
setSelectedLinkIds: Dispatch<SetStateAction<string[]>>;
lastSelectedLinkId: string | null;
Expand All @@ -24,6 +26,7 @@ export function LinkSelectionProvider({
children: React.ReactNode;
links?: ResponseLink[];
}) {
const [isSelectMode, setIsSelectMode] = useState(false);
const [selectedLinkIds, setSelectedLinkIds] = useState<string[]>([]);
const [lastSelectedLinkId, setLastSelectedLinkId] = useState<string | null>(
null,
Expand Down Expand Up @@ -72,6 +75,8 @@ export function LinkSelectionProvider({
return (
<LinkSelectionContext.Provider
value={{
isSelectMode,
setIsSelectMode,
selectedLinkIds,
setSelectedLinkIds,
lastSelectedLinkId,
Expand Down
130 changes: 72 additions & 58 deletions apps/web/ui/links/link-title-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,10 @@ const LOGO_SIZE_CLASS_NAME =
"size-4 sm:size-6 group-data-[variant=loose]/card-list:sm:size-5";

export function LinkTitleColumn({ link }: { link: ResponseLink }) {
const { url, domain, key } = link;
const { domain, key } = link;

const { variant } = useContext(CardList.Context);
const { displayProperties } = useContext(LinksDisplayContext);
const { selectedLinkIds, handleLinkSelection } = useLinkSelection();

const ref = useRef<HTMLDivElement>(null);

Expand All @@ -79,8 +78,6 @@ export function LinkTitleColumn({ link }: { link: ResponseLink }) {
const { folders } = useFolders();
const folder = folders?.find((folder) => folder.id === link.folderId);

const isSelected = selectedLinkIds.includes(link.id);

return (
<div
ref={ref}
Expand All @@ -101,65 +98,15 @@ export function LinkTitleColumn({ link }: { link: ResponseLink }) {
)}
</Link>
)}
<button
type="button"
role="checkbox"
aria-checked={isSelected}
data-checked={isSelected}
onClick={(e) => handleLinkSelection(link.id, e)}
className="group relative hidden shrink-0 items-center justify-center outline-none sm:flex"
>
{/* Link logo background circle */}
<div className="absolute inset-0 shrink-0 rounded-full border border-neutral-200 opacity-0 transition-opacity group-data-[variant=loose]/card-list:sm:opacity-100">
<div className="h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100" />
</div>
<div className="relative transition-[padding,transform] group-hover:scale-90 group-data-[variant=loose]/card-list:sm:p-2">
{link.archived ? (
<BoxArchive
className={cn(
"shrink-0 p-0.5 text-neutral-600 transition-[width,height]",
LOGO_SIZE_CLASS_NAME,
)}
/>
) : (
<LinkLogo
apexDomain={getApexDomain(url)}
className={cn(
"shrink-0 transition-[width,height]",
LOGO_SIZE_CLASS_NAME,
)}
imageProps={{
loading: "lazy",
}}
/>
)}
</div>
{/* Checkbox */}
<div
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center rounded-full border border-neutral-400 bg-white ring-0 ring-black/5",
"opacity-0 transition-all duration-150 group-hover:opacity-100 group-hover:ring group-focus-visible:opacity-100 group-focus-visible:ring",
"group-data-[checked=true]:opacity-100",
)}
>
<div
className={cn(
"rounded-full bg-neutral-800 p-0.5 group-data-[variant=loose]/card-list:p-1",
"scale-90 opacity-0 transition-[transform,opacity] duration-100 group-data-[checked=true]:scale-100 group-data-[checked=true]:opacity-100",
)}
>
<Check2 className="size-3 text-white" />
</div>
</div>
</button>
<LinkIcon link={link} />
<div className="h-[24px] min-w-0 overflow-hidden transition-[height] group-data-[variant=loose]/card-list:h-[46px]">
<div className="flex items-center gap-2">
<div className="min-w-0 shrink grow-0 text-neutral-950">
<div className="flex items-center gap-2">
{displayProperties.includes("title") && link.title ? (
<span
className={cn(
"truncate font-semibold leading-6 text-neutral-800",
"min-w-0 truncate font-semibold leading-6 text-neutral-800",
link.archived && "text-neutral-600",
)}
>
Expand All @@ -173,7 +120,7 @@ export function LinkTitleColumn({ link }: { link: ResponseLink }) {
rel="noopener noreferrer"
title={linkConstructor({ domain, key, pretty: true })}
className={cn(
"truncate font-semibold leading-6 text-neutral-800 transition-colors hover:text-black",
"font-semibold leading-6 text-neutral-800 transition-colors hover:text-black",
link.archived && "text-neutral-600",
)}
>
Expand Down Expand Up @@ -216,7 +163,7 @@ function UnverifiedTooltip({
const { verified } = useDomain({ slug: domain, enabled: isVisible });

return (
<div ref={ref}>
<div ref={ref} className="min-w-0 truncate">
{!isDubDomain(domain) && verified === false ? (
<Tooltip
content={
Expand Down Expand Up @@ -288,6 +235,73 @@ function SettingsBadge({ link }: { link: ResponseLink }) {
);
}

const LinkIcon = memo(({ link }: { link: ResponseLink }) => {
const { isSelectMode, selectedLinkIds, handleLinkSelection } =
useLinkSelection();
const isSelected = selectedLinkIds.includes(link.id);

return (
<button
type="button"
role="checkbox"
aria-checked={isSelected}
data-checked={isSelected}
onClick={(e) => handleLinkSelection(link.id, e)}
className={cn(
"group relative hidden shrink-0 items-center justify-center outline-none sm:flex",
isSelectMode && "flex",
)}
>
{/* Link logo background circle */}
<div className="absolute inset-0 shrink-0 rounded-full border border-neutral-200 opacity-0 transition-opacity group-data-[variant=loose]/card-list:sm:opacity-100">
<div className="h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100" />
</div>
<div className="relative transition-[padding,transform] group-hover:scale-90 group-data-[variant=loose]/card-list:sm:p-2">
<div className="hidden sm:block">
{link.archived ? (
<BoxArchive
className={cn(
"shrink-0 p-0.5 text-neutral-600 transition-[width,height]",
LOGO_SIZE_CLASS_NAME,
)}
/>
) : (
<LinkLogo
apexDomain={getApexDomain(link.url)}
className={cn(
"shrink-0 transition-[width,height]",
LOGO_SIZE_CLASS_NAME,
)}
imageProps={{
loading: "lazy",
}}
/>
)}
</div>
<div className="size-5 group-data-[variant=loose]/card-list:size-6 sm:hidden" />
</div>
{/* Checkbox */}
<div
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center rounded-full border border-neutral-400 bg-white ring-0 ring-black/5",
"opacity-100 max-sm:ring sm:opacity-0",
"transition-all duration-150 group-hover:opacity-100 group-hover:ring group-focus-visible:opacity-100 group-focus-visible:ring",
"group-data-[checked=true]:opacity-100",
)}
>
<div
className={cn(
"rounded-full bg-neutral-800 p-0.5 group-data-[variant=loose]/card-list:p-1",
"scale-90 opacity-0 transition-[transform,opacity] duration-100 group-data-[checked=true]:scale-100 group-data-[checked=true]:opacity-100",
)}
>
<Check2 className="size-3 text-white" />
</div>
</div>
</button>
);
});

const Details = memo(
({ link, compact }: { link: ResponseLink; compact?: boolean }) => {
const { url, createdAt } = link;
Expand Down
Loading

0 comments on commit 0cbb018

Please sign in to comment.