File size: 6,676 Bytes
1cce69a
 
3c9ef83
1cce69a
 
 
d18f742
1cce69a
66cec88
 
 
 
 
8aa4e8b
 
 
 
66cec88
633c0de
 
 
 
 
 
 
d18f742
 
633c0de
 
 
66cec88
 
 
 
 
1cce69a
 
c4e0821
 
 
633c0de
c4e0821
633c0de
 
 
c4e0821
1cce69a
 
 
 
 
d18f742
 
 
 
 
 
 
 
1cce69a
 
66cec88
 
 
 
 
d18f742
66cec88
 
 
 
 
 
 
 
 
 
8aa4e8b
 
 
 
 
633c0de
8aa4e8b
 
 
 
 
 
 
1cce69a
 
 
eb12d9b
 
d18f742
1cce69a
 
 
 
 
d18f742
 
1cce69a
 
 
 
3c9ef83
 
 
 
 
d18f742
3c9ef83
 
 
 
d18f742
3c9ef83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d18f742
3c9ef83
 
 
 
 
 
 
d18f742
 
3c9ef83
 
 
d18f742
3c9ef83
 
d18f742
 
3c9ef83
 
 
 
 
 
 
 
 
 
d18f742
3c9ef83
 
 
 
 
 
 
a4f19d4
3c9ef83
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"use client";

import React, { useEffect, useRef, useState } from "react";
import { useAuth } from "@/context/auth-context";

const SIGNIN_BADGE_URL =
  "https://huggingface.co/datasets/huggingface/badges/resolve/main/sign-in-with-huggingface-md-dark.svg";

// `badge` β€” the official HF brand badge. Use as a strong invitation when the
//           auth path is itself the page's headline action.
// `ghost`  β€” a quiet inline cyan link, sized to the surrounding body copy.
//           Use when auth is a secondary affordance next to a primary CTA
//           (e.g. the home page's search bar).
// `tab`    β€” uppercase tracked text styled to match a tab strip; pairs with
//           the episode viewer's tab bar so the auth control reads as part
//           of the same register.
type Variant = "badge" | "ghost" | "tab";

// Slot height per variant. Matches the variant's rendered button so the
// pre-config placeholder (when isAuthAvailable hasn't resolved yet) and the
// signed-in/signed-out states all occupy exactly the same vertical space β€”
// no layout shift on auth state changes. `tab` is taller because it lives
// in the episode tab bar and needs to align with the `text-xs px-5 py-3`
// tab buttons (~40px implicit height).
const SLOT_HEIGHT: Record<Variant, string> = {
  badge: "h-8",
  ghost: "h-7",
  tab: "h-10",
};

interface HfAuthButtonProps {
  variant?: Variant;
}

export default function HfAuthButton({ variant = "badge" }: HfAuthButtonProps) {
  const { oauth, isAuthAvailable, signIn, signOut } = useAuth();

  // Stable slot β€” auth state resolves async on mount (config fetch, then
  // localStorage rehydrate), so the rendered control changes from
  // null β†’ signed-out β†’ signed-in. Reserve the height so the surrounding
  // layout doesn't reflow each time.
  if (!isAuthAvailable) {
    return (
      <span aria-hidden className={`inline-block ${SLOT_HEIGHT[variant]}`} />
    );
  }

  if (oauth) {
    const name =
      oauth.userInfo?.preferred_username ?? oauth.userInfo?.name ?? "signed in";
    const avatar = oauth.userInfo?.picture;
    return (
      <SignedInMenu
        name={name}
        avatar={avatar}
        onSignOut={signOut}
        variant={variant}
      />
    );
  }

  if (variant === "ghost") {
    return (
      <button
        onClick={signIn}
        title="Sign in to access your private datasets"
        className="cursor-pointer inline-flex items-center h-7 gap-1.5 text-sm tracking-wide text-cyan-300/85 hover:text-cyan-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
      >
        <span aria-hidden>πŸ€—</span>
        <span>Sign in for private datasets</span>
        <span aria-hidden className="opacity-60">
          β†’
        </span>
      </button>
    );
  }

  if (variant === "tab") {
    return (
      <button
        onClick={signIn}
        title="Sign in to access your private datasets"
        className="cursor-pointer inline-flex items-center h-10 gap-1.5 px-5 text-[11px] font-medium tracking-wide uppercase text-slate-400 hover:text-cyan-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
      >
        <span aria-hidden>πŸ€—</span>
        <span>Sign in</span>
      </button>
    );
  }

  return (
    <button
      onClick={signIn}
      title="Sign in with Hugging Face to access your private datasets"
      aria-label="Sign in with Hugging Face to access your private datasets"
      className="cursor-pointer inline-flex items-center h-8 rounded-md transition-all duration-150 hover:opacity-90 motion-safe:hover:-translate-y-px focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={SIGNIN_BADGE_URL}
        alt="Sign in with Hugging Face"
        height={32}
        className="h-8 w-auto"
      />
    </button>
  );
}

function SignedInMenu({
  name,
  avatar,
  onSignOut,
  variant,
}: {
  name: string;
  avatar?: string;
  onSignOut: () => void;
  variant: Variant;
}) {
  const [open, setOpen] = useState(false);
  const wrapperRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      if (
        wrapperRef.current &&
        !wrapperRef.current.contains(e.target as Node)
      ) {
        setOpen(false);
      }
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  return (
    <div ref={wrapperRef} className="relative inline-flex">
      <button
        onClick={() => setOpen((v) => !v)}
        aria-haspopup="menu"
        aria-expanded={open}
        className={`cursor-pointer inline-flex items-center ${SLOT_HEIGHT[variant]} gap-2 panel-raised bg-[var(--surface-0)]/85 backdrop-blur px-2 text-xs text-slate-300 hover:bg-white/[0.04] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60`}
        title={`Signed in as ${name}`}
      >
        {avatar && (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={avatar}
            alt=""
            width={22}
            height={22}
            className="rounded-full ring-1 ring-white/10"
          />
        )}
        <span className="tabular max-w-[10rem] truncate">{name}</span>
        <svg
          aria-hidden
          width="9"
          height="9"
          viewBox="0 0 8 8"
          className={`text-slate-500 transition-transform ${open ? "rotate-180" : ""}`}
        >
          <path d="M1 2.5l3 3 3-3" stroke="currentColor" fill="none" />
        </svg>
      </button>

      {open && (
        <div
          role="menu"
          className="absolute right-0 top-full mt-1.5 min-w-[10rem] panel-raised bg-[var(--surface-1)]/98 backdrop-blur shadow-xl p-1 z-50 text-xs animate-menu-pop"
        >
          <button
            role="menuitem"
            onClick={() => {
              setOpen(false);
              onSignOut();
            }}
            className="cursor-pointer w-full text-left px-2 py-1.5 rounded text-slate-300 hover:bg-white/5 hover:text-slate-100 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
          >
            Sign out
          </button>
        </div>
      )}
    </div>
  );
}