File size: 6,024 Bytes
a23efda
 
 
a461926
a23efda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b626c
a23efda
f4b626c
a23efda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b626c
a23efda
f4b626c
a23efda
 
 
 
f4b626c
a23efda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b626c
a23efda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b626c
a23efda
 
 
 
 
 
 
 
 
2618817
a23efda
 
 
 
 
 
 
 
 
ae285b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b626c
ae285b0
 
 
f4b626c
ae285b0
 
 
 
 
 
a461926
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
"use client";

import React from "react";
import { setTheme, useTheme } from "@/utils/theme";

/**
 * Shared building blocks for the analysis tabs (Statistics, Filtering, Frames, Action Insights), so
 * their headings, cards, loading states and toggles look the same everywhere.
 */

/** Tab title and one-line description, with an optional control on the right. */
export function PageHeader({
  title,
  description,
  children,
}: {
  title: string;
  description?: React.ReactNode;
  children?: React.ReactNode;
}) {
  return (
    <div className="flex items-start justify-between flex-wrap gap-4">
      <div className="min-w-0">
        <h2 className="text-xl font-semibold text-fg">{title}</h2>
        {description && (
          <p className="text-sm text-fg-muted mt-1">{description}</p>
        )}
      </div>
      {children && <div className="shrink-0">{children}</div>}
    </div>
  );
}

/** A labelled number: the label styled like the sidebar's, the value large and tabular. */
export function StatCard({
  label,
  value,
}: {
  label: string;
  value: React.ReactNode;
}) {
  return (
    <div className="rounded-lg border border-line bg-[var(--surface-1)]/60 px-4 py-3 min-w-0">
      <p
        className="text-[10px] uppercase tracking-wide text-fg-faint truncate"
        title={label}
      >
        {label}
      </p>
      <p className="text-lg font-semibold text-fg tabular mt-1 truncate">
        {value}
      </p>
    </div>
  );
}

export function Spinner({ className = "h-4 w-4" }: { className?: string }) {
  return (
    <svg
      className={`animate-spin ${className}`}
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden="true"
    >
      <circle
        className="opacity-25"
        cx="12"
        cy="12"
        r="10"
        stroke="currentColor"
        strokeWidth="4"
      />
      <path
        className="opacity-75"
        fill="currentColor"
        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
      />
    </svg>
  );
}

/** A spinner with a label, for a section that is still loading. */
export function InlineLoading({ label }: { label: string }) {
  return (
    <div
      className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted"
      role="status"
    >
      <Spinner />
      {label}
    </div>
  );
}

/** Two-state switch with a label on each side; the active side is highlighted. */
export function Switch({
  checked,
  onChange,
  offLabel,
  onLabel,
  ariaLabel,
}: {
  checked: boolean;
  onChange: (checked: boolean) => void;
  offLabel: React.ReactNode;
  onLabel: React.ReactNode;
  ariaLabel: string;
}) {
  const labelClass = (active: boolean) =>
    `text-sm ${active ? "text-fg font-medium" : "text-fg-faint"}`;
  return (
    <div className="flex items-center gap-3">
      <span className={labelClass(!checked)}>{offLabel}</span>
      <button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-label={ariaLabel}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${checked ? "bg-accent" : "bg-fill-strong"}`}
      >
        <span
          className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
        />
      </button>
      <span className={labelClass(checked)}>{onLabel}</span>
    </div>
  );
}

/** Repo and episode above the videos, on every tab that plays an episode. */
export function EpisodeHeader({
  repoId,
  episodeId,
}: {
  repoId: string;
  episodeId: number;
}) {
  return (
    <div className="flex items-center gap-4">
      <a
        href="https://github.com/huggingface/lerobot"
        target="_blank"
        rel="noopener noreferrer"
        className="block shrink-0 opacity-90 hover:opacity-100 transition-opacity"
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src="https://github.com/huggingface/lerobot/raw/main/media/readme/lerobot-logo-thumbnail.png"
          alt="LeRobot Logo"
          className="w-24"
        />
      </a>
      <div className="min-w-0">
        <a
          href={`https://huggingface.co/datasets/${repoId}`}
          target="_blank"
          rel="noopener noreferrer"
          className="text-fg hover:text-accent-fg transition-colors"
        >
          <p className="text-base font-medium truncate">{repoId}</p>
        </a>
        <p className="text-[10px] uppercase tracking-wide text-fg-faint mt-0.5 tabular">
          Episode · {episodeId}
        </p>
      </div>
    </div>
  );
}

/** Switches between the light and dark theme; the choice is remembered. */
export function ThemeToggle() {
  const theme = useTheme();
  const next = theme === "dark" ? "light" : "dark";
  return (
    <button
      type="button"
      onClick={() => setTheme(next)}
      title={`Switch to ${next} theme`}
      aria-label={`Switch to ${next} theme`}
      className="flex h-8 w-8 items-center justify-center rounded-md text-fg-muted hover:text-fg hover:bg-fill transition-colors"
    >
      {theme === "dark" ? (
        <svg
          width="15"
          height="15"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <circle cx="12" cy="12" r="4" />
          <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
        </svg>
      ) : (
        <svg
          width="15"
          height="15"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
        </svg>
      )}
    </button>
  );
}