v3: a feed card opens in a new tab on a middle click, wherever you point — the cards are clickable elements rather than links, so only the title, the one real anchor, answered a middle click; the rest of the card, the play button included, did nothing, and a middle press there started the browser's autoscroll instead. A shared useDumpCardLink now gives DumpCard and JournalCard an auxclick that opens the dump in a new tab, with the middle press's default suppressed so autoscroll doesn't swallow it, and a ctrl/cmd/shift click does the same — caught ahead of the play button, so a modified click on a playable card opens it rather than playing it, while a plain click still plays. Anchors inside a card keep their own target: the title, category chips, and a source link's ↗ still open what they point at.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useCategories } from "../hooks/useCategories.ts";
|
import { useCategories } from "../hooks/useCategories.ts";
|
||||||
|
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||||
import FilePreview from "./FilePreview.tsx";
|
import FilePreview from "./FilePreview.tsx";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
@@ -28,7 +29,7 @@ export function DumpCard(
|
|||||||
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
|
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
|
||||||
DumpCardProps,
|
DumpCardProps,
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { byId } = useCategories();
|
const { byId } = useCategories();
|
||||||
const dumpCategories = (dump.categoryIds ?? [])
|
const dumpCategories = (dump.categoryIds ?? [])
|
||||||
@@ -37,21 +38,17 @@ export function DumpCard(
|
|||||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||||
!isDumpVisited(dump.id);
|
!isDumpVisited(dump.id);
|
||||||
|
|
||||||
function handleNavigate() {
|
|
||||||
markDumpVisited(dump.id);
|
|
||||||
navigate(dumpUrl(dump));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
||||||
<div
|
<div
|
||||||
className="dump-card-inner"
|
className="dump-card-inner"
|
||||||
onClick={handleNavigate}
|
{...cardProps}
|
||||||
>
|
>
|
||||||
<div className="dump-card-left">
|
<div className="dump-card-left">
|
||||||
<div
|
<div
|
||||||
className="dump-card-preview"
|
className="dump-card-preview"
|
||||||
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
||||||
|
onClickCapture={handledAsNewTab}
|
||||||
>
|
>
|
||||||
{dump.kind === "file"
|
{dump.kind === "file"
|
||||||
? <FilePreview dump={dump} compact />
|
? <FilePreview dump={dump} compact />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||||
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
|
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
|
||||||
import { VoteButton } from "./VoteButton.tsx";
|
import { VoteButton } from "./VoteButton.tsx";
|
||||||
@@ -32,22 +33,17 @@ export function JournalCard(
|
|||||||
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||||
JournalCardProps,
|
JournalCardProps,
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { playRichContent } = usePlayRichContent();
|
const { playRichContent } = usePlayRichContent();
|
||||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||||
!isDumpVisited(dump.id);
|
!isDumpVisited(dump.id);
|
||||||
|
|
||||||
function handleNavigate() {
|
|
||||||
markDumpVisited(dump.id);
|
|
||||||
navigate(dumpUrl(dump));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
||||||
// page with no streams and no embed to fall back to — the card must still do
|
// page with no streams and no embed to fall back to — the card must still do
|
||||||
// what an unplayable one does rather than swallowing the click.
|
// what an unplayable one does rather than swallowing the click.
|
||||||
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
||||||
if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
|
if (!await playRichContent(rc, dumpUrl(dump))) go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||||
@@ -157,9 +153,12 @@ export function JournalCard(
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={className}
|
className={className}
|
||||||
|
{...cardProps}
|
||||||
onClick={playable
|
onClick={playable
|
||||||
? () => void handlePlayOrNavigate(playable)
|
? (e) => {
|
||||||
: handleNavigate}
|
if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable);
|
||||||
|
}
|
||||||
|
: cardProps.onClick}
|
||||||
>
|
>
|
||||||
<div className="journal-card-image">
|
<div className="journal-card-image">
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
@@ -196,7 +195,7 @@ export function JournalCard(
|
|||||||
|
|
||||||
if (mode === "quote") {
|
if (mode === "quote") {
|
||||||
return (
|
return (
|
||||||
<li className={className} onClick={handleNavigate}>
|
<li className={className} {...cardProps}>
|
||||||
<span className="journal-card-quotemark" aria-hidden="true">“</span>
|
<span className="journal-card-quotemark" aria-hidden="true">“</span>
|
||||||
<Markdown className="journal-card-quote-body" inline>
|
<Markdown className="journal-card-quote-body" inline>
|
||||||
{dump.comment!}
|
{dump.comment!}
|
||||||
@@ -211,7 +210,7 @@ export function JournalCard(
|
|||||||
|
|
||||||
// text
|
// text
|
||||||
return (
|
return (
|
||||||
<li className={className} onClick={handleNavigate}>
|
<li className={className} {...cardProps}>
|
||||||
<span className="journal-card-glyph" aria-hidden="true">
|
<span className="journal-card-glyph" aria-hidden="true">
|
||||||
{fallbackIcon}
|
{fallbackIcon}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
64
src/hooks/useDumpCardLink.ts
Normal file
64
src/hooks/useDumpCardLink.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import type { Dump } from "../model.ts";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
|
import { markDumpVisited } from "../utils/visited.ts";
|
||||||
|
|
||||||
|
function isOverLink(e: MouseEvent) {
|
||||||
|
return (e.target as Element).closest("a") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wantsNewTab(e: MouseEvent) {
|
||||||
|
return e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed card is a clickable element rather than a link, so it gets none of a
|
||||||
|
* link's behaviour for free. This gives it back: a middle click (or a
|
||||||
|
* ctrl/cmd/shift click) anywhere on the card opens the dump in a new tab, even
|
||||||
|
* over a play button whose plain click starts the media. Anchors inside the
|
||||||
|
* card (the title, a source link, category chips) keep their own target.
|
||||||
|
*/
|
||||||
|
export function useDumpCardLink(dump: Pick<Dump, "id" | "slug">) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
function openInNewTab() {
|
||||||
|
markDumpVisited(dump.id);
|
||||||
|
globalThis.open(dumpUrl(dump), "_blank", "noopener");
|
||||||
|
}
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
markDumpVisited(dump.id);
|
||||||
|
navigate(dumpUrl(dump));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Navigates in place. */
|
||||||
|
go,
|
||||||
|
/** For click handlers that do something other than navigate (play):
|
||||||
|
* returns true when the click was a new-tab request and was handled. */
|
||||||
|
handledAsNewTab(e: MouseEvent) {
|
||||||
|
if (!wantsNewTab(e) || isOverLink(e)) return false;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openInNewTab();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cardProps: {
|
||||||
|
onClick(e: MouseEvent) {
|
||||||
|
if (wantsNewTab(e)) openInNewTab();
|
||||||
|
else go();
|
||||||
|
},
|
||||||
|
// Windows and Linux browsers start autoscroll on a middle press, which
|
||||||
|
// eats the auxclick that follows.
|
||||||
|
onMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
|
||||||
|
},
|
||||||
|
onAuxClick(e: MouseEvent) {
|
||||||
|
if (e.button !== 1 || isOverLink(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openInNewTab();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user