Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion packages/web/src/components/AccessGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { Turnstile } from "@marsidev/react-turnstile";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { api, SESSION_AUTH_KEY, setSessionToken } from "@/lib/api";
import {
api,
SESSION_ANON_KEY,
SESSION_AUTH_KEY,
setSessionToken,
} from "@/lib/api";
import { cn } from "@/lib/utils";
import { StatsLayout } from "./layouts/StatsLayout";

Expand Down Expand Up @@ -43,6 +49,7 @@ type AccessGateProps = {
export function AccessGate({ onAuthenticated }: AccessGateProps) {
const [password, setPassword] = useState(getPassParam);
const turnstileTokenRef = useRef<string | null>(null);
const [turnstileReady, setTurnstileReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [turnstileFailed, setTurnstileFailed] = useState(false);
const [needsInteraction, setNeedsInteraction] = useState(false);
Expand Down Expand Up @@ -88,12 +95,14 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {
setError(data.error ?? "Verification failed");
setPassword("");
turnstileTokenRef.current = null;
setTurnstileReady(false);
setTurnstileKey((k) => k + 1);
}
} catch {
setError("Network error. Please try again.");
setPassword("");
turnstileTokenRef.current = null;
setTurnstileReady(false);
setTurnstileKey((k) => k + 1);
} finally {
loadingRef.current = false;
Expand All @@ -114,6 +123,7 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {

function handleTurnstileVerify(token: string) {
turnstileTokenRef.current = token;
setTurnstileReady(true);
setTurnstileFailed(false);
setNeedsInteraction(false);
if (password.length === 6 && !loadingRef.current) {
Expand All @@ -123,9 +133,48 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {

function handleTurnstileFailed() {
turnstileTokenRef.current = null;
setTurnstileReady(false);
setTurnstileFailed(true);
}

async function continueAnonymously() {
const token = turnstileTokenRef.current;
if (!token || loadingRef.current) {
return;
}
loadingRef.current = true;
setError(null);

try {
const res = await api.api.auth.anonymous.$post({
json: { turnstileToken: token },
});

if (res.ok) {
const data = (await res.json()) as { token?: string };
if (data.token) {
setSessionToken(data.token);
}
localStorage.setItem(SESSION_AUTH_KEY, "1");
localStorage.setItem(SESSION_ANON_KEY, "1");
onAuthenticated();
} else {
const data = (await res.json()) as { error?: string };
setError(data.error ?? "Could not start anonymous session");
turnstileTokenRef.current = null;
setTurnstileReady(false);
setTurnstileKey((k) => k + 1);
}
} catch {
setError("Network error. Please try again.");
turnstileTokenRef.current = null;
setTurnstileReady(false);
setTurnstileKey((k) => k + 1);
} finally {
loadingRef.current = false;
}
}

return (
<StatsLayout title="Password Required">
<Card className="mx-auto max-w-sm p-8">
Expand Down Expand Up @@ -168,6 +217,26 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {
<p className="text-center text-sm text-[#ff6669]">{displayError}</p>
) : null}

<div className="flex w-full flex-col items-stretch gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<span>or</span>
<span className="h-px flex-1 bg-border" />
</div>
<Button
type="button"
variant="neutral"
size="sm"
onClick={continueAnonymously}
disabled={!turnstileReady || codeComplete}
>
Continue anonymously
</Button>
<p className="text-center text-xs text-muted-foreground">
Browse stats with usernames hidden.
</p>
</div>

<Turnstile
key={turnstileKey}
siteKey={TURNSTILE_SITE_KEY}
Expand All @@ -181,6 +250,7 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {
onBeforeInteractive={() => setNeedsInteraction(true)}
onExpire={() => {
turnstileTokenRef.current = null;
setTurnstileReady(false);
}}
options={{ theme: "dark" }}
hidden={!needsInteraction}
Expand Down
43 changes: 43 additions & 0 deletions packages/web/src/components/AnonymousAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getIdenticonSeed } from "@/lib/anonymous";
import { cn } from "@/lib/utils";

type AnonymousAvatarProps = {
userId: string;
size?: number;
};

const GRID = 5;

export function AnonymousAvatar({ userId, size = 28 }: AnonymousAvatarProps) {
const { cells, hue } = getIdenticonSeed(userId);
const fg = `oklch(70% 0.18 ${hue})`;
const bg = `oklch(22% 0.05 ${hue})`;

return (
<svg
aria-label="Anonymous user avatar"
role="img"
viewBox={`0 0 ${GRID} ${GRID}`}
shapeRendering="crispEdges"
width={size}
height={size}
className={cn("rounded-full")}
style={{ background: bg }}
>
{cells.map((on, i) => {
if (!on) {
return null;
}
const col = i % 3;
const row = Math.floor(i / 3);
const key = `${row}-${col}`;
return (
<g key={key}>
<rect x={col} y={row} width={1} height={1} fill={fg} />
<rect x={GRID - 1 - col} y={row} width={1} height={1} fill={fg} />
</g>
);
})}
</svg>
);
}
25 changes: 25 additions & 0 deletions packages/web/src/components/AnonymousBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { clearSession, isAnonymousMode } from "@/lib/api";

export function AnonymousBanner() {
if (!isAnonymousMode()) {
return null;
}

function signIn() {
clearSession();
window.location.reload();
}

return (
<div className="fixed bottom-3 right-3 z-50 flex items-center gap-2 rounded-base border-2 border-border bg-secondary-background px-3 py-2 text-xs shadow-shadow">
<span className="text-muted-foreground">Anonymous mode</span>
<button
type="button"
onClick={signIn}
className="cursor-pointer font-semibold text-main hover:underline"
>
Sign in
</button>
</div>
);
}
29 changes: 29 additions & 0 deletions packages/web/src/components/AnonymousName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getRedactionLength } from "@/lib/anonymous";
import { cn } from "@/lib/utils";

type AnonymousNameProps = {
userId: string;
className?: string;
};

export function AnonymousName({ userId, className }: AnonymousNameProps) {
const length = getRedactionLength(userId);

return (
<span
aria-label="Anonymous user"
className={cn(
"inline-block align-middle select-none rounded-sm",
"bg-[length:4px_4px] bg-[image:repeating-linear-gradient(45deg,oklch(28%_0.02_280)_0,oklch(28%_0.02_280)_2px,oklch(52%_0.03_280)_2px,oklch(52%_0.03_280)_4px)]",
className,
)}
style={{
width: `${length}ch`,
height: "0.95em",
color: "transparent",
}}
>
{"█".repeat(length)}
</span>
);
}
16 changes: 11 additions & 5 deletions packages/web/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { AnonymousAvatar } from "@/components/AnonymousAvatar";
import { cn } from "@/lib/utils";

type AvatarProps = {
url?: string | null;
name?: string | null;
userId?: string;
size?: number;
};

export function Avatar({ url, name, size = 28 }: AvatarProps) {
export function Avatar({ url, name, userId, size = 28 }: AvatarProps) {
const sizeClass = size === 40 ? "h-10 w-10" : "h-7 w-7";

if (!url && !name) {
return null;
}

if (url) {
return (
<img
Expand All @@ -24,6 +22,14 @@ export function Avatar({ url, name, size = 28 }: AvatarProps) {
);
}

if (userId) {
return <AnonymousAvatar userId={userId} size={size} />;
}

if (!name) {
return null;
}

return (
<div
className={cn(
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/components/UserName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AnonymousName } from "@/components/AnonymousName";

type UserNameProps = {
userId: string;
displayName?: string | null;
className?: string;
};

export function UserName({ userId, displayName, className }: UserNameProps) {
if (displayName) {
return <span className={className}>{displayName}</span>;
}
return <AnonymousName userId={userId} className={className} />;
}
8 changes: 7 additions & 1 deletion packages/web/src/components/layouts/BaseLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { ReactNode } from "react";
import { AnonymousBanner } from "@/components/AnonymousBanner";

type LayoutProps = {
children: ReactNode;
};
export function BaseLayout({ children }: LayoutProps) {
return <div className="min-h-dvh">{children}</div>;
return (
<div className="min-h-dvh">
{children}
<AnonymousBanner />
</div>
);
}
14 changes: 8 additions & 6 deletions packages/web/src/components/parrots/ParrotProfileCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { Emoji } from "@/components/stats/Emoji";
import { UserName } from "@/components/UserName";
import { Card } from "@/components/ui/card";
import { useEmojiProfile } from "@/hooks/queries";

Expand All @@ -8,8 +10,8 @@ export function ParrotProfileCard({ emoji }: { emoji: string }) {
const { data } = useEmojiProfile(emoji);

const firstUsedAt = formatMonthYear(data?.firstUsedAt) ?? DASH;
const firstUser = userLabel(data?.firstUser) ?? DASH;
const topUser = userLabel(data?.topUser) ?? DASH;
const firstUser = userNode(data?.firstUser) ?? DASH;
const topUser = userNode(data?.topUser) ?? DASH;
const totalCount = data ? data.totalCount.toLocaleString() : DASH;

return (
Expand All @@ -32,7 +34,7 @@ export function ParrotProfileCard({ emoji }: { emoji: string }) {
);
}

function Stat({ label, value }: { label: string; value: string }) {
function Stat({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs uppercase tracking-wider text-parrot">{label}</dt>
Expand All @@ -41,13 +43,13 @@ function Stat({ label, value }: { label: string; value: string }) {
);
}

function userLabel(
function userNode(
user: { displayName: string; userId: string } | null | undefined,
): string | null {
): ReactNode {
if (!user) {
return null;
}
return user.displayName || user.userId;
return <UserName userId={user.userId} displayName={user.displayName} />;
}

function formatMonthYear(raw: string | null | undefined): string | null {
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/stats/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";

type DetailViewProps = {
icon: ReactNode;
title: string;
title: ReactNode;
loading: boolean;
error?: string;
emptyMessage: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/stats/LeaderboardRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from "react";
type LeaderboardRowProps = {
rank: number;
left: ReactNode;
label: string;
label: ReactNode;
count: number;
onClick?: () => void;
};
Expand Down
10 changes: 7 additions & 3 deletions packages/web/src/components/stats/Trends.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
YAxis,
} from "recharts";
import { useMediaQuery } from "usehooks-ts";
import { AnonymousAvatar } from "@/components/AnonymousAvatar";
import { UserName } from "@/components/UserName";
import {
useCategoryData,
useEmojiTrends,
Expand Down Expand Up @@ -216,11 +218,13 @@ function UserTrendsChart() {
{user?.avatar ? (
<img
src={user.avatar}
alt={user.name}
alt={user.name ?? "User"}
className="h-3.5 w-3.5 rounded-full"
/>
) : null}
{user?.name || userId}
) : (
<AnonymousAvatar userId={userId} size={14} />
)}
<UserName userId={userId} displayName={user?.name} />
</span>
),
color: CHART_COLORS[i % CHART_COLORS.length],
Expand Down
Loading