quiz link
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
|||||||
type QuizResult,
|
type QuizResult,
|
||||||
} from "@/lib/quiz";
|
} from "@/lib/quiz";
|
||||||
import { QuizResultView } from "@/components/quiz/quiz-result-view";
|
import { QuizResultView } from "@/components/quiz/quiz-result-view";
|
||||||
|
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Stage =
|
type Stage =
|
||||||
@@ -170,6 +171,10 @@ export function QuizPlayer({ id }: { id: string }) {
|
|||||||
{stage.card.target_title || stage.card.target_path}
|
{stage.card.target_title || stage.card.target_path}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{/* Shares the quiz itself; the link unfurls with its own OG card. */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<CopyPageLinkButton label="Copy quiz link" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Copy link to this page" — copies the full, unshortened absolute URL of
|
||||||
|
* the active page. Deliberately bypasses useCopyFeedback (which rewrites
|
||||||
|
* `?hl=` highlight URLs into short /share links); here we want the literal
|
||||||
|
* page URL. The write is synchronous within the click so a single tap works.
|
||||||
|
*
|
||||||
|
* Shared by the PageHeader controls row and pages with their own header
|
||||||
|
* (e.g. a quiz).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, Link2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function CopyPageLinkButton({
|
||||||
|
label = "Copy link to this page",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyPageLink() {
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch {
|
||||||
|
// Fallback for browsers without the async clipboard API.
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = url;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
// silent fail
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyPageLink}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
|
||||||
|
copied
|
||||||
|
? "text-foreground border-primary/40"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-3.5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Link2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? "Link copied" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { Check, Link2 } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
|
||||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||||
import { QuizButton } from "@/components/quiz/quiz-button";
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import type { OfflineDescriptor } from "@/lib/offline";
|
import type { OfflineDescriptor } from "@/lib/offline";
|
||||||
@@ -153,43 +153,6 @@ export function PageHeader({
|
|||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, [sticky]);
|
}, [sticky]);
|
||||||
|
|
||||||
// "Copy link to this page" — copies the full, unshortened absolute URL of
|
|
||||||
// the active page. Deliberately bypasses useCopyFeedback (which rewrites
|
|
||||||
// `?hl=` highlight URLs into short /share links); here we want the literal
|
|
||||||
// page URL. The write is synchronous within the click so a single tap works.
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function copyPageLink() {
|
|
||||||
const url = window.location.href;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
} catch {
|
|
||||||
// Fallback for browsers without the async clipboard API.
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = url;
|
|
||||||
ta.style.position = "fixed";
|
|
||||||
ta.style.opacity = "0";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
} catch {
|
|
||||||
// silent fail
|
|
||||||
}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
||||||
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -228,25 +191,7 @@ export function PageHeader({
|
|||||||
{/* First, so it reads as belonging to the title above it. Renders
|
{/* First, so it reads as belonging to the title above it. Renders
|
||||||
nothing on pages without a published quiz. */}
|
nothing on pages without a published quiz. */}
|
||||||
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
||||||
<button
|
<CopyPageLinkButton />
|
||||||
type="button"
|
|
||||||
onClick={copyPageLink}
|
|
||||||
aria-label="Copy link to this page"
|
|
||||||
title="Copy link to this page"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
|
|
||||||
copied
|
|
||||||
? "text-foreground border-primary/40"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="size-3.5 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Link2 className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{copied ? "Link copied" : "Copy link to this page"}
|
|
||||||
</button>
|
|
||||||
{resolvedOffline ? (
|
{resolvedOffline ? (
|
||||||
<SaveOfflineButton descriptor={resolvedOffline} />
|
<SaveOfflineButton descriptor={resolvedOffline} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user