mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
More consistent dashboard (#1613)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/hexclave/hexclave/blob/dev/CONTRIBUTING.md --> ## Summary This PR tightens dashboard consistency across the project list, team invite flow, hosted auth previews, and shared overlay surfaces. It also includes smaller visual fixes for charts/tooltips, globe sizing, sticky data-grid surfaces, and dashboard/hosted component light-mode parity. **Base:** `dev@2c5b2d02c` -> **Head:** `more-consistent-dashboard@b1eae206` **Capture notes:** screenshots were captured locally at `1440x1000` after signing in through GitHub -> `admin@example.com` on the mock OAuth server. Each page/state was allowed to settle for 5 seconds immediately before capture. Dark mode is used by default; light mode is included only for the light-specific overlay/dialog surfaces changed in this PR. ## Visual Comparison ### Hosted Auth Preview - Dark Mode | Before | After | | --- | --- | |  |  | ### Team Invite Dialog - Light-Specific Surface | Before | After | | --- | --- | |  |  | ## What's Changed - Reworks the team invite dialog with clearer loading/retry states, seat information, capacity warnings, pending invites, revoke actions, and refresh behavior. - Adds a dashboard-local hosted auth preview used by Auth Methods and onboarding-style flows, showing sign-in/sign-up methods with project display names and disabled sign-up behavior. - Standardizes overlay and popover surfaces across dashboard, hosted components, and shared UI primitives for better light/dark consistency. - Adjusts chart, tooltip, globe, sidebar drawer, data-grid, and email-log presentation details to reduce clipping, bleed, and stacking issues. ## Notes For Reviewers - The visual comparison focuses on places where this PR has visible UI differences: project cards/actions, the team invite modal, overlay menu styling, and the hosted auth preview. - Lower-level primitive background/ring changes are represented through the menu/dialog screenshots rather than separate screenshots for every consuming component. ## Test Plan - Captured the before screenshots on `dev@2c5b2d02c` and the after screenshots on `more-consistent-dashboard@b1eae206`. - Waited 5 seconds on each page/state before capturing screenshots. - Uploaded exactly 10 screenshots to a fresh gist and embedded them above. - Not run in this pass: code tests, because this pass only updates the PR description/screenshots. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a live hosted authentication preview for project setup and auth methods, including sign-in/sign-up states and multiple login options. * **Bug Fixes** * Improved invite dialog behavior so seat limits, plan upgrades, loading states, retries, and invitation refreshes stay in sync. * Fixed chart tooltip and globe display behavior so overlays, sizing, and labels render more reliably. * **Style** * Refreshed dialogs, menus, tooltips, charts, and sidebars with improved light/dark theme styling and spacing. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: armaan <armaan@stack-auth.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
244fc3ab7b
commit
db94ec2d94
@ -55,6 +55,10 @@ vi.mock("@/components/design-components/select", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/hosted-auth-preview", () => ({
|
||||
HostedAuthMethodPreview: () => <div>Hosted auth preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/router", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
@ -93,7 +97,6 @@ vi.mock("@/components/config-update", () => ({
|
||||
|
||||
vi.mock("@hexclave/next", () => ({
|
||||
AdminOwnedProject: class {},
|
||||
AuthPage: () => <div>Auth preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@hexclave/shared/dist/utils/oauth", () => ({
|
||||
|
||||
@ -4,6 +4,7 @@ import { DesignCard, DesignPillToggle } from "@/components/design-components";
|
||||
import { DesignAlert } from "@/components/design-components/alert";
|
||||
import { DesignButton } from "@/components/design-components/button";
|
||||
import { DesignSelectorDropdown } from "@/components/design-components/select";
|
||||
import { HostedAuthMethodPreview } from "@/components/hosted-auth-preview";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { StripeWordmark } from "@/components/stripe-wordmark";
|
||||
import {
|
||||
@ -30,7 +31,7 @@ import {
|
||||
WarningCircleIcon,
|
||||
WebhooksLogoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AuthPage, type AdminOwnedProject } from "@hexclave/next";
|
||||
import { type AdminOwnedProject } from "@hexclave/next";
|
||||
import { type AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import { type EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
import { previewTemplateSource } from "@hexclave/shared/dist/helpers/emails";
|
||||
@ -249,7 +250,7 @@ export function ProjectOnboardingWizard(props: {
|
||||
|
||||
const authPreviewProject = useMemo(() => {
|
||||
return {
|
||||
id: project.id,
|
||||
displayName: project.displayName,
|
||||
config: {
|
||||
signUpEnabled: true,
|
||||
credentialEnabled: signInMethods.has("credential"),
|
||||
@ -260,7 +261,7 @@ export function ProjectOnboardingWizard(props: {
|
||||
.map((providerId) => ({ id: providerId, type: "shared" as const })),
|
||||
},
|
||||
};
|
||||
}, [project.id, signInMethods]);
|
||||
}, [project.displayName, signInMethods]);
|
||||
|
||||
const toggleSignInMethod = (method: SignInMethod, enabled: boolean) => {
|
||||
setSignInMethods((previous) => {
|
||||
@ -824,9 +825,7 @@ export function ProjectOnboardingWizard(props: {
|
||||
<div className="flex min-h-[180px] items-center justify-center px-4 py-3 sm:min-h-[220px] md:min-h-[260px] md:px-5 md:py-4 lg:min-h-[300px]">
|
||||
<div className="pointer-events-none relative flex w-full items-center justify-center" inert>
|
||||
<div className="absolute inset-0 z-10 bg-transparent" />
|
||||
<div className="auth-preview-host-theme flex w-full justify-center">
|
||||
<AuthPage type="sign-in" mockProject={authPreviewProject} />
|
||||
</div>
|
||||
<HostedAuthMethodPreview project={authPreviewProject} />
|
||||
</div>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
|
||||
@ -754,7 +754,7 @@ export default function PageClient() {
|
||||
)}
|
||||
{showTooltip && analyticsCardHoveredIndex !== null && (
|
||||
<div
|
||||
className="absolute z-20 -top-2 -translate-x-1/2 -translate-y-full rounded-xl bg-background/95 border border-foreground/[0.1] shadow-lg px-3 py-2 min-w-[170px]"
|
||||
className="absolute z-20 -top-2 -translate-x-1/2 -translate-y-full rounded-xl bg-white/95 border border-black/[0.08] shadow-lg ring-1 ring-black/[0.06] px-3 py-2 min-w-[170px] dark:bg-background/95 dark:border-white/[0.08] dark:ring-white/[0.08]"
|
||||
style={{ left: `${tooltipLeftPercent}%` }}
|
||||
>
|
||||
<div className="text-[10px] font-medium text-muted-foreground">{hoveredPoint.date}</div>
|
||||
|
||||
@ -4,10 +4,11 @@ import { Link } from "@/components/link";
|
||||
import { ProjectCard } from "@/components/project-card";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { DesignAlert, DesignBadge, DesignButton, DesignDialog, DesignInput } from "@/components/design-components";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Typography, toast } from "@/components/ui";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
|
||||
import { FileCode, GearIcon } from "@phosphor-icons/react";
|
||||
import { FileCode, GearIcon, UserPlusIcon } from "@phosphor-icons/react";
|
||||
import { AdminOwnedProject, Team, useStackApp, useUser } from "@hexclave/next";
|
||||
import { isPaidPlan } from "@hexclave/shared/dist/plans";
|
||||
import { projectOnboardingStatusValues, strictEmailSchema, yupObject, type ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields";
|
||||
@ -17,7 +18,7 @@ import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@hexclave/s
|
||||
import { useQueryState } from "@hexclave/shared/dist/utils/react";
|
||||
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { inviteUser, listInvitations, revokeInvitation } from "./actions";
|
||||
import Footer from "./footer";
|
||||
@ -507,9 +508,37 @@ const inviteFormSchema = yupObject({
|
||||
email: strictEmailSchema("Please enter a valid email address").defined(),
|
||||
});
|
||||
|
||||
type TeamAddUserDialogData = {
|
||||
invitations: Awaited<ReturnType<typeof listInvitations>>,
|
||||
userCount: number,
|
||||
seatLimit: number,
|
||||
hasPaidPlan: boolean,
|
||||
};
|
||||
|
||||
async function loadTeamAddUserDialogData(team: Team): Promise<TeamAddUserDialogData> {
|
||||
const [invitations, users, admins, products] = await Promise.all([
|
||||
listInvitations(team.id),
|
||||
team.listUsers(),
|
||||
team.getItem("dashboard_admins"),
|
||||
team.listProducts(),
|
||||
]);
|
||||
|
||||
return {
|
||||
invitations,
|
||||
userCount: users.length,
|
||||
seatLimit: admins.quantity,
|
||||
hasPaidPlan: isPaidPlan(products),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function TeamAddUserDialog(props: { team: Team }) {
|
||||
const [teamSettingsId, setTeamSettingsId] = useQueryState("team_settings");
|
||||
const [dialogData, setDialogData] = useState<TeamAddUserDialogData | null>(null);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const open = teamSettingsId === props.team.id;
|
||||
const setOpen = (isOpen: boolean) => {
|
||||
@ -520,86 +549,58 @@ function TeamAddUserDialog(props: { team: Team }) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Invite teammates to ${props.team.displayName}`}
|
||||
title={`Invite teammates to ${props.team.displayName}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<GearIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite a new user to {props.team.displayName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Suspense fallback={<TeamAddUserDialogContentSkeleton />}>
|
||||
<TeamAddUserDialogContent
|
||||
team={props.team}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamAddUserDialogContent(props: {
|
||||
team: Team,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
const [invitations, setInvitations] = useState<Awaited<ReturnType<typeof listInvitations>>>();
|
||||
const [invitationsError, setInvitationsError] = useState<string | null>(null);
|
||||
|
||||
const fetchInvitations = useCallback(async () => {
|
||||
setInvitationsError(null);
|
||||
const fetchDialogData = useCallback(async (isCanceled: () => boolean = () => false) => {
|
||||
setLoadingData(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const invitations = await listInvitations(props.team.id);
|
||||
setInvitations(invitations);
|
||||
const data = await loadTeamAddUserDialogData(props.team);
|
||||
if (!isCanceled()) {
|
||||
setDialogData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
setInvitationsError("Failed to load invitations. Please try again.");
|
||||
captureError("team-admin-invite-dialog-load", error);
|
||||
if (!isCanceled()) {
|
||||
setLoadError("Failed to load team admin seats. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
if (!isCanceled()) {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
}, [props.team.id]);
|
||||
}, [props.team]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFormError(null);
|
||||
setDialogData(null);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogData(null);
|
||||
let canceled = false;
|
||||
runAsynchronously(async () => {
|
||||
try {
|
||||
const invitations = await listInvitations(props.team.id);
|
||||
if (!canceled) {
|
||||
setInvitations(invitations);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!canceled) {
|
||||
setInvitationsError("Failed to load invitations. Please try again.");
|
||||
}
|
||||
}
|
||||
});
|
||||
runAsynchronously(fetchDialogData(() => canceled));
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [props.team.id]);
|
||||
}, [fetchDialogData, open]);
|
||||
|
||||
const users = props.team.useUsers();
|
||||
const admins = props.team.useItem("dashboard_admins");
|
||||
const products = props.team.useProducts();
|
||||
const hasPaidPlan = isPaidPlan(products);
|
||||
const refreshInvitations = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
try {
|
||||
const data = await loadTeamAddUserDialogData(props.team);
|
||||
setDialogData(data);
|
||||
} catch (error) {
|
||||
captureError("team-admin-invite-dialog-refresh-invitations", error);
|
||||
setLoadError("Failed to refresh pending invitations. Please try again.");
|
||||
}
|
||||
}, [props.team]);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const invitationsLoaded = invitations != null;
|
||||
const activeSeats = users.length + (invitations?.length ?? 0);
|
||||
const seatLimit = admins.quantity;
|
||||
const atCapacity = invitationsLoaded && activeSeats >= seatLimit;
|
||||
const activeSeats = dialogData == null ? null : dialogData.userCount + dialogData.invitations.length;
|
||||
const atCapacity = dialogData != null && activeSeats != null && activeSeats >= dialogData.seatLimit;
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!invitationsLoaded || atCapacity) {
|
||||
if (dialogData == null || atCapacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -609,7 +610,7 @@ function TeamAddUserDialogContent(props: {
|
||||
await inviteUser(props.team.id, values.email, window.location.origin);
|
||||
toast({ variant: "success", title: "Team invitation sent" });
|
||||
setEmail("");
|
||||
await fetchInvitations();
|
||||
await refreshInvitations();
|
||||
} catch (error) {
|
||||
if (error instanceof yup.ValidationError) {
|
||||
setFormError(error.errors[0] ?? error.message);
|
||||
@ -636,152 +637,184 @@ function TeamAddUserDialogContent(props: {
|
||||
window.location.assign(checkoutUrl);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<DesignButton variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</DesignButton>
|
||||
{atCapacity ? (
|
||||
dialogData.hasPaidPlan ? (
|
||||
<DesignButton size="sm" type="button" onClick={handleAddSeat}>
|
||||
Add seat ($29/mo)
|
||||
</DesignButton>
|
||||
) : (
|
||||
<DesignButton size="sm" type="button" onClick={handleUpgrade}>
|
||||
Upgrade plan
|
||||
</DesignButton>
|
||||
)
|
||||
) : (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleInvite}
|
||||
disabled={dialogData == null || loadingData}
|
||||
>
|
||||
Invite
|
||||
</DesignButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<Typography type="label">Dashboard admin seats</Typography>
|
||||
{invitationsLoaded ? (
|
||||
<Typography variant="secondary">
|
||||
{activeSeats}/{seatLimit}
|
||||
</Typography>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-12" />
|
||||
)}
|
||||
<DesignDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
size="lg"
|
||||
icon={UserPlusIcon}
|
||||
title={`Invite a user to ${props.team.displayName}`}
|
||||
description="Add a dashboard admin and keep pending invitations visible."
|
||||
trigger={(
|
||||
<DesignButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Invite teammates to ${props.team.displayName}`}
|
||||
title={`Invite teammates to ${props.team.displayName}`}
|
||||
>
|
||||
<GearIcon className="h-4 w-4" />
|
||||
</DesignButton>
|
||||
)}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-foreground/[0.08] bg-foreground/[0.02] p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Dashboard admin seats
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
{dialogData == null
|
||||
? "Checking seats and pending invitations..."
|
||||
: `${dialogData.userCount} active admin${dialogData.userCount === 1 ? "" : "s"} plus ${dialogData.invitations.length} pending invitation${dialogData.invitations.length === 1 ? "" : "s"}.`}
|
||||
</Typography>
|
||||
</div>
|
||||
{activeSeats == null || dialogData == null ? (
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
) : (
|
||||
<DesignBadge
|
||||
label={`${activeSeats}/${dialogData.seatLimit}`}
|
||||
color={atCapacity ? "orange" : "green"}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{atCapacity && (
|
||||
<Typography variant="secondary" className="text-destructive">
|
||||
{hasPaidPlan
|
||||
? "You are at capacity. Add an extra seat for $29/month."
|
||||
: "You are at capacity. Upgrade your plan to add more admins."}
|
||||
</Typography>
|
||||
|
||||
{loadError != null && (
|
||||
<DesignAlert
|
||||
variant="error"
|
||||
title="Could not load team data"
|
||||
description={loadError}
|
||||
className="p-3"
|
||||
>
|
||||
<DesignButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 h-7 px-2"
|
||||
type="button"
|
||||
onClick={() => fetchDialogData()}
|
||||
>
|
||||
Retry
|
||||
</DesignButton>
|
||||
</DesignAlert>
|
||||
)}
|
||||
|
||||
{atCapacity && (
|
||||
<DesignAlert
|
||||
variant="warning"
|
||||
title="No admin seats available"
|
||||
description={dialogData.hasPaidPlan
|
||||
? "Add an extra seat for $29/month to invite another dashboard admin."
|
||||
: "Upgrade your plan to invite more dashboard admins."}
|
||||
className="p-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
New admin
|
||||
</Typography>
|
||||
<DesignInput
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
if (formError) {
|
||||
if (formError != null) {
|
||||
setFormError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Email"
|
||||
placeholder="admin@example.com"
|
||||
type="email"
|
||||
disabled={(!invitationsLoaded && !invitationsError) || atCapacity}
|
||||
disabled={dialogData == null || loadingData || atCapacity}
|
||||
autoFocus
|
||||
/>
|
||||
{formError && (
|
||||
<Typography type="label" className="text-destructive">
|
||||
{formError != null && (
|
||||
<Typography type="label" className="text-xs text-destructive">
|
||||
{formError}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Typography type="label">Pending invitations</Typography>
|
||||
{invitationsError ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/5 px-3 py-2">
|
||||
<Typography variant="secondary" className="text-destructive text-sm">
|
||||
{invitationsError}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Pending invitations
|
||||
</Typography>
|
||||
{dialogData != null && dialogData.invitations.length > 0 && (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
{dialogData.invitations.length} pending
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialogData == null && loadingData ? (
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
) : dialogData == null ? (
|
||||
null
|
||||
) : dialogData.invitations.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-foreground/[0.12] bg-foreground/[0.02] px-3 py-3">
|
||||
<Typography variant="secondary" className="text-sm">
|
||||
No pending invitations
|
||||
</Typography>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchInvitations}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : invitations?.length === 0 ? (
|
||||
<Typography variant="secondary">None</Typography>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{invitations?.map((invitation) => (
|
||||
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{dialogData.invitations.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-foreground/[0.08] bg-white/60 px-3 py-2 dark:bg-foreground/[0.03]"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Typography>{invitation.recipientEmail ?? "Pending invitation"}</Typography>
|
||||
</div>
|
||||
<Button
|
||||
<Typography className="min-w-0 truncate text-sm">
|
||||
{invitation.recipientEmail ?? "Pending invitation"}
|
||||
</Typography>
|
||||
<DesignButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="h-7 px-2"
|
||||
onClick={async () => {
|
||||
await revokeInvitation(props.team.id, invitation.id);
|
||||
await fetchInvitations();
|
||||
await refreshInvitations();
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DesignButton>
|
||||
</div>
|
||||
))}
|
||||
{!invitations && (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{atCapacity ? (
|
||||
hasPaidPlan ? (
|
||||
<Button onClick={handleAddSeat} variant="default">
|
||||
Add seat ($29/mo)
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleUpgrade} variant="default">
|
||||
Upgrade plan
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button onClick={handleInvite} disabled={!invitationsLoaded && !invitationsError}>
|
||||
Invite
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamAddUserDialogContentSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<Typography type="label">Dashboard admin seats</Typography>
|
||||
<div className="stack-scope text-md text-zinc-600 dark:text-zinc-400">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
disabled
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Typography type="label">Pending invitations</Typography>
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Button variant="outline" disabled>
|
||||
Close
|
||||
</Button>
|
||||
<Button disabled>
|
||||
Invite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
</DesignDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { useWaitForIdle } from '@/hooks/use-wait-for-idle';
|
||||
import { useDashboardUser } from '@/lib/dashboard-user';
|
||||
import { useThemeWatcher } from '@/lib/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import { UserAvatar } from '@hexclave/next';
|
||||
import type { MetricsRecentUser } from '@hexclave/shared/dist/interface/admin-metrics';
|
||||
import { throwErr } from '@hexclave/shared/dist/utils/errors';
|
||||
@ -22,6 +21,7 @@ import {
|
||||
Group,
|
||||
Mesh,
|
||||
MeshLambertMaterial,
|
||||
PerspectiveCamera,
|
||||
SphereGeometry,
|
||||
Vector3,
|
||||
} from 'three';
|
||||
@ -35,32 +35,24 @@ export const globeImages = {
|
||||
const Globe = dynamic(() => import('react-globe.gl').then((mod) => mod.default), { ssr: false });
|
||||
const countriesPromise = import('./country-data.geo.json');
|
||||
|
||||
function useSize(target: RefObject<HTMLDivElement | null>) {
|
||||
const [size, setSize] = useState<DOMRectReadOnly>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize(target.current?.getBoundingClientRect());
|
||||
}, [target]);
|
||||
|
||||
// Where the magic happens
|
||||
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
||||
return size;
|
||||
}
|
||||
|
||||
function calculateGlobeVisualDiameter(globeRef: RefObject<GlobeMethods | undefined>): number {
|
||||
if (!globeRef.current) return 0;
|
||||
|
||||
const current = globeRef.current;
|
||||
const globeRadius = current.getGlobeRadius();
|
||||
const camera = current.camera();
|
||||
if (!(camera instanceof PerspectiveCamera)) {
|
||||
throwErr("Expected react-globe.gl to use a PerspectiveCamera for globe sizing");
|
||||
}
|
||||
const renderer = current.renderer();
|
||||
const centerWorld = new Vector3(0, 0, 0);
|
||||
const cameraPosition = camera.position;
|
||||
const distanceToCenter = centerWorld.distanceTo(cameraPosition);
|
||||
const fov = (camera as any).fov * (Math.PI / 180);
|
||||
const screenHeight = renderer.domElement.height;
|
||||
const visualRadius = (globeRadius / distanceToCenter) * (screenHeight / (2 * Math.tan(fov / 2)));
|
||||
return visualRadius * 1.065; // Return diameter
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
const cssHeight = renderer.domElement.getBoundingClientRect().height;
|
||||
if (cssHeight <= 0) return 0;
|
||||
const visualRadius = (globeRadius / distanceToCenter) * (cssHeight / (2 * Math.tan(fov / 2)));
|
||||
return visualRadius * 2 * 1.065;
|
||||
}
|
||||
|
||||
// --- Country point-in-polygon (used by the orbiting satellites to detect
|
||||
@ -408,8 +400,8 @@ function GlobeLoading(props: { devReason: string, className?: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("w-full aspect-square flex items-center justify-center", props.className)}>
|
||||
<div className="relative w-[70%] aspect-square">
|
||||
<div className={cn("w-full h-full flex items-center justify-center", props.className)}>
|
||||
<div className="relative aspect-square h-[70%] max-w-[70%]">
|
||||
{/* Main globe circle with gradient */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-sky-500/[0.09] via-sky-400/[0.05] to-transparent animate-pulse dark:from-sky-400/[0.12] dark:via-sky-500/[0.06]"
|
||||
@ -487,12 +479,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
[activeUsersByCountry, countryVizData],
|
||||
);
|
||||
|
||||
// Only `globeContainerSize` is actually consumed (drives zoom / border math
|
||||
// further down). The other refs/useSize calls were leftovers from an earlier
|
||||
// layout and each spawned a live ResizeObserver subscription for no reason.
|
||||
const globeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const globeContainerSize = useSize(globeContainerRef);
|
||||
|
||||
// Measure the parent element so the root can size itself to min(w, h) of
|
||||
// the available space (container queries misfire here on initial layout).
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@ -502,7 +488,8 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
if (!parent) return;
|
||||
const update = () => {
|
||||
const r = parent.getBoundingClientRect();
|
||||
setParentBox({ w: r.width, h: r.height });
|
||||
const nextBox = { w: Math.floor(r.width), h: Math.floor(r.height) };
|
||||
setParentBox((current) => current.w === nextBox.w && current.h === nextBox.h ? current : nextBox);
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
@ -511,9 +498,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
}, []);
|
||||
const squareSize = Math.min(parentBox.w, parentBox.h);
|
||||
|
||||
// Simplified sizing for the new layout - only use width
|
||||
const globeSize = globeContainerSize?.width ?? 400;
|
||||
|
||||
// Calculate camera distance (zoom) based on canvas width
|
||||
// Linear interpolation: zoom decreases as width increases (less aggressive slope)
|
||||
// Lower zoom values = larger globe size
|
||||
@ -523,10 +507,10 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
// - Canvas width >= 500: zoom stays at 309 so the globe keeps a constant
|
||||
// visual fill ratio on widescreens instead of growing without bound and
|
||||
// overflowing the canvas.
|
||||
const canvasWidth = globeContainerSize?.width ?? 0;
|
||||
const canvasWidth = squareSize;
|
||||
const GLOBE_MIN_WIDTH = 350;
|
||||
|
||||
const shouldShowGlobe = canvasWidth >= GLOBE_MIN_WIDTH;
|
||||
const shouldShowGlobe = squareSize > 0 && canvasWidth >= GLOBE_MIN_WIDTH;
|
||||
|
||||
// Calculate zoom based on width
|
||||
// For widths >= 355, use linear formula clamped to a minimum distance.
|
||||
@ -536,19 +520,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
? Math.max(MIN_CAMERA_DISTANCE, 436 - 0.35 * canvasWidth)
|
||||
: 325; // For 350-355 range
|
||||
|
||||
// Calculate border size using exact same formula structure as cameraDistance
|
||||
// Uses same scale factor (0.35) but inverted direction (increases as width increases)
|
||||
// - Canvas width 350: Hide border
|
||||
// - Canvas width 355: borderSize = BORDER_BASE_SIZE
|
||||
// - Canvas width 500: borderSize = BORDER_BASE_SIZE + 0.35 * (500 - 355)
|
||||
// Formula: borderSize = BORDER_BASE_SIZE + 0.35 * (width - 355) for width >= 355
|
||||
const BORDER_BASE_SIZE = 180; // Only variable to change - base size at 355px
|
||||
const borderSize = canvasWidth >= 355
|
||||
? BORDER_BASE_SIZE + 0.35 * (canvasWidth - 355)
|
||||
: canvasWidth >= GLOBE_MIN_WIDTH
|
||||
? BORDER_BASE_SIZE
|
||||
: 0;
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState<{ code: string, name: string } | null>(null);
|
||||
const [previousSelectedCountry, setPreviousSelectedCountry] = useState<{ code: string, name: string } | null>(null);
|
||||
const lastSelectedCountry = selectedCountry ?? previousSelectedCountry;
|
||||
@ -704,7 +675,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
const visualDiameter = calculateGlobeVisualDiameter(globeRef);
|
||||
setBorderSizeFromGlobe(visualDiameter);
|
||||
resumeRender();
|
||||
}, [cameraDistance, shouldShowGlobe, globeSize, interactive]);
|
||||
}, [cameraDistance, shouldShowGlobe, squareSize, interactive]);
|
||||
|
||||
|
||||
const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0);
|
||||
@ -1068,7 +1039,14 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className='relative mx-auto overflow-hidden' style={{ width: squareSize || '100%', height: squareSize || '100%' }}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className='relative mx-auto overflow-hidden'
|
||||
style={{
|
||||
width: squareSize > 0 ? squareSize : '100%',
|
||||
height: squareSize > 0 ? squareSize : '100%',
|
||||
}}
|
||||
>
|
||||
<div inert className='absolute inset-0 pointer-events-none'>
|
||||
<GlobeLoading
|
||||
devReason="not ready"
|
||||
@ -1084,9 +1062,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
globeReady ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{/* Hidden measurement div - always rendered to track size */}
|
||||
<div ref={globeContainerRef} className='absolute inset-0 pointer-events-none' aria-hidden="true" />
|
||||
|
||||
{/* Globe Container - Premium 3D */}
|
||||
{shouldShowGlobe && (
|
||||
<div className='relative w-full h-full overflow-hidden flex items-center justify-center'>
|
||||
@ -1157,10 +1132,10 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
backgroundColor='rgba(0,0,0,0)'
|
||||
// globeImageUrl={globeImages[theme]}
|
||||
globeMaterial={globeMaterial}
|
||||
width={globeSize}
|
||||
width={squareSize}
|
||||
showGraticules={theme === 'dark'}
|
||||
showAtmosphere={false}
|
||||
height={globeSize}
|
||||
height={squareSize}
|
||||
onGlobeReady={() => {
|
||||
setGlobeReady(true);
|
||||
|
||||
@ -1352,7 +1327,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
style={{ transform: 'translate3d(0px, 0px, 0) translate(-50%, -140%)' }}
|
||||
>
|
||||
<div className='flex flex-col items-center gap-1.5'>
|
||||
<div className='flex items-center gap-1.5 px-2 py-1 rounded-full bg-background/95 ring-1 ring-foreground/10 shadow-lg backdrop-blur-md'>
|
||||
<div className='flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/95 ring-1 ring-black/[0.08] shadow-lg backdrop-blur-md dark:bg-background/95 dark:ring-white/[0.08]'>
|
||||
{display?.user && (
|
||||
<div className='w-7 h-7 rounded-full overflow-hidden ring-2 ring-sky-400/60 dark:ring-sky-300/50'>
|
||||
<UserAvatar
|
||||
@ -1390,7 +1365,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-[100] min-w-[180px] p-4 rounded-2xl shadow-xl bg-background/95 backdrop-blur-xl ring-1 ring-foreground/[0.08] pointer-events-none",
|
||||
"fixed top-0 left-0 z-[100] min-w-[180px] p-4 rounded-2xl shadow-xl bg-white/95 backdrop-blur-xl ring-1 ring-black/[0.08] pointer-events-none dark:bg-background/95 dark:ring-white/[0.08]",
|
||||
selectedCountry ? 'opacity-100' : 'opacity-0 transition-opacity duration-300 ease-out',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -826,7 +826,7 @@ export function ComposedAnalyticsChart({
|
||||
<ComposedChart
|
||||
id={id}
|
||||
data={taggedDatapoints}
|
||||
margin={{ top: 10, right: 4, left: 4, bottom: 0 }}
|
||||
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
|
||||
onMouseMove={(state) => {
|
||||
updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex);
|
||||
setHoveredX(getActiveCoordinateX(state));
|
||||
@ -976,7 +976,7 @@ export function ComposedAnalyticsChart({
|
||||
yAxisId="visitors"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={compact ? 28 : 36}
|
||||
width={compact ? 35 : 50}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 9 : 10 }}
|
||||
ticks={visitorTicks}
|
||||
domain={[0, visitorsMax]}
|
||||
@ -2434,7 +2434,7 @@ export function VisitorsHoverChart({
|
||||
>
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
|
||||
onMouseMove={(state) => updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
@ -2525,7 +2525,7 @@ export function VisitorsHoverChart({
|
||||
tickMargin={4}
|
||||
tick={{ fontSize: compact ? 9 : 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
allowDecimals={false}
|
||||
width={28}
|
||||
width={compact ? 35 : 50}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
@ -2650,7 +2650,7 @@ export function RevenueHoverChart({
|
||||
>
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
|
||||
onMouseMove={(state) => updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
@ -2770,7 +2770,7 @@ export function RevenueHoverChart({
|
||||
tick={{ fontSize: compact ? 9 : 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
ticks={ticksCents}
|
||||
tickFormatter={(v: number) => formatUsdCompact(v)}
|
||||
width={36}
|
||||
width={compact ? 35 : 50}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
|
||||
@ -526,7 +526,7 @@ function FilterMenuContent({
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-[min(30rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-foreground/[0.08] bg-background p-0 shadow-lg"
|
||||
className="w-[min(30rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-black/[0.08] bg-white p-0 shadow-lg ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-background dark:ring-white/[0.08]"
|
||||
>
|
||||
<DropdownMenuLabel className="px-3 py-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Filter analytics by
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { InlineSaveDiscard } from "@/components/inline-save-discard";
|
||||
import { ActionDialog, BrandIcons, BrowserFrame, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, Label, SimpleTooltip, Switch, Typography } from "@/components/ui";
|
||||
import { FormDialog } from "@/components/form-dialog";
|
||||
import { HostedAuthMethodPreview } from "@/components/hosted-auth-preview";
|
||||
import { useUpdateConfig } from "@/components/config-update";
|
||||
import { useDashboardInternalUser } from "@/lib/dashboard-user";
|
||||
import {
|
||||
@ -31,7 +32,7 @@ import {
|
||||
UserCircleIcon,
|
||||
UserPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AdminProject, AuthPage } from "@hexclave/next";
|
||||
import { AdminProject } from "@hexclave/next";
|
||||
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
|
||||
import { urlSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
@ -751,12 +752,14 @@ function ProviderInlineRow({ provider }: { provider: AdminOAuthProviderConfig })
|
||||
|
||||
function LivePreviewBody({
|
||||
config,
|
||||
projectDisplayName,
|
||||
passwordEnabled,
|
||||
otpEnabled,
|
||||
passkeyEnabled,
|
||||
enabledProviders,
|
||||
}: {
|
||||
config: AdminProject['config'],
|
||||
projectDisplayName: string,
|
||||
passwordEnabled: boolean,
|
||||
otpEnabled: boolean,
|
||||
passkeyEnabled: boolean,
|
||||
@ -768,15 +771,15 @@ function LivePreviewBody({
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||||
<div className='w-full sm:max-w-xs m-auto scale-90 pointer-events-none' inert>
|
||||
<div className="absolute inset-0 bg-transparent z-10"></div>
|
||||
<AuthPage
|
||||
type="sign-in"
|
||||
mockProject={{
|
||||
<HostedAuthMethodPreview
|
||||
project={{
|
||||
displayName: projectDisplayName,
|
||||
config: {
|
||||
...config,
|
||||
signUpEnabled: config.signUpEnabled,
|
||||
credentialEnabled: passwordEnabled,
|
||||
magicLinkEnabled: otpEnabled,
|
||||
passkeyEnabled: passkeyEnabled,
|
||||
oauthProviders: enabledProviders,
|
||||
passkeyEnabled,
|
||||
oauthProviders: enabledProviders.map((provider) => ({ id: provider.id })),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@ -1145,6 +1148,7 @@ export default function PageClient() {
|
||||
>
|
||||
<LivePreviewBody
|
||||
config={project.config}
|
||||
projectDisplayName={project.displayName}
|
||||
passwordEnabled={passwordEnabled}
|
||||
otpEnabled={otpEnabled}
|
||||
passkeyEnabled={passkeyEnabled}
|
||||
|
||||
@ -172,7 +172,7 @@ export function KpiBlock({
|
||||
</div>
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute left-4 right-4 top-full z-20 mt-1.5 rounded-xl border border-foreground/10 bg-background/95 p-3 opacity-0 shadow-[0_12px_28px_rgba(15,23,42,0.18)] backdrop-blur-xl transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:shadow-[0_12px_28px_rgba(0,0,0,0.55)]"
|
||||
className="pointer-events-none absolute left-4 right-4 top-full z-20 mt-1.5 rounded-xl border border-black/[0.08] bg-white/95 p-3 opacity-0 shadow-[0_12px_28px_rgba(15,23,42,0.18)] ring-1 ring-black/[0.06] backdrop-blur-xl transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:border-white/[0.08] dark:bg-background/95 dark:ring-white/[0.08] dark:shadow-[0_12px_28px_rgba(0,0,0,0.55)]"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
||||
@ -193,7 +193,7 @@ function DemoChartTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
? date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: data.date;
|
||||
return (
|
||||
<div className="rounded-xl bg-background/95 px-3.5 py-2.5 shadow-lg backdrop-blur-xl ring-1 ring-foreground/[0.08]">
|
||||
<div className="rounded-xl bg-white/95 px-3.5 py-2.5 shadow-lg backdrop-blur-xl ring-1 ring-black/[0.08] dark:bg-background/95 dark:ring-white/[0.08]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[11px] font-medium text-muted-foreground tracking-wide">{formattedDate}</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
@ -258,16 +258,9 @@ export default function PageClient() {
|
||||
contentClassName="p-3 min-w-0"
|
||||
gradient="default"
|
||||
glassmorphic
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 rounded-md bg-foreground/[0.06] dark:bg-foreground/[0.04]">
|
||||
<Envelope className="h-3 w-3 text-foreground/70 dark:text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-foreground uppercase tracking-wider">
|
||||
Email Log
|
||||
</span>
|
||||
</div>
|
||||
title="Email Log"
|
||||
icon={Envelope}
|
||||
actions={(
|
||||
<DesignPillToggle
|
||||
options={[...VIEW_MODE_OPTIONS]}
|
||||
selected={viewMode}
|
||||
@ -275,7 +268,8 @@ export default function PageClient() {
|
||||
size="sm"
|
||||
gradient="default"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{viewMode === "list" ? <EmailSendDataTable /> : <GroupedEmailTable />}
|
||||
</DesignCard>
|
||||
</div>
|
||||
|
||||
@ -239,8 +239,8 @@ export function ProductDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className={cn(
|
||||
"max-w-2xl rounded-2xl",
|
||||
"bg-background/95 backdrop-blur-xl",
|
||||
"border border-border/50 dark:border-foreground/[0.1]",
|
||||
"bg-white/95 backdrop-blur-xl dark:bg-background/95",
|
||||
"border border-black/[0.08] dark:border-foreground/[0.1]",
|
||||
"shadow-2xl"
|
||||
)}>
|
||||
<Stepper currentStep={currentStep} onStepChange={setCurrentStep} className="min-h-[400px]">
|
||||
|
||||
@ -352,7 +352,7 @@ function Timeline({
|
||||
className="absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50"
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
<div className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground whitespace-nowrap max-w-52">
|
||||
<div className="max-w-52 whitespace-nowrap rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08]">
|
||||
<div className="truncate">{hoveredMarker.label}</div>
|
||||
<div className="text-[10px] opacity-70">{formatTimelineMs(hoveredMarker.timeMs)}</div>
|
||||
</div>
|
||||
|
||||
@ -468,11 +468,13 @@ function SidebarContent({
|
||||
onNavigate,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
isDrawer = false,
|
||||
}: {
|
||||
projectId: string,
|
||||
onNavigate?: () => void,
|
||||
isCollapsed?: boolean,
|
||||
onToggleCollapse?: () => void,
|
||||
isDrawer?: boolean,
|
||||
}) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const pathname = usePathname();
|
||||
@ -611,7 +613,11 @@ function SidebarContent({
|
||||
<div className="flex-grow" />
|
||||
</div>
|
||||
|
||||
<div className={cn("sticky bottom-0 border-t border-black/[0.06] dark:border-foreground/10 py-3 transition-all duration-200 dark:backdrop-blur-xl dark:rounded-b-2xl", isCollapsed ? "px-2" : "px-3")}>
|
||||
<div className={cn(
|
||||
"sticky bottom-0 border-t border-black/[0.06] dark:border-foreground/10 py-3 transition-all duration-200 dark:backdrop-blur-xl",
|
||||
!isDrawer && "dark:rounded-b-2xl",
|
||||
isCollapsed ? "px-2" : "px-3",
|
||||
)}>
|
||||
<div className="space-y-2">
|
||||
{bottomItems.map((item) => (
|
||||
<NavItem
|
||||
@ -748,7 +754,7 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
|
||||
className="w-[248px] bg-white/90 dark:bg-foreground/5 border-black/[0.06] dark:border-foreground/5 p-0 backdrop-blur-sm shadow-md"
|
||||
hasCloseButton={false}
|
||||
>
|
||||
<SidebarContent projectId={projectId} onNavigate={() => setSidebarOpen(false)} />
|
||||
<SidebarContent projectId={projectId} onNavigate={() => setSidebarOpen(false)} isDrawer />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@ -222,10 +222,19 @@
|
||||
}
|
||||
|
||||
.analytics-card-tooltip-escape .recharts-tooltip-wrapper {
|
||||
z-index: 9999 !important;
|
||||
z-index: 2147483647 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.analytics-card-tooltip-escape:has(.recharts-tooltip-wrapper[style*="visibility: visible"]) {
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
|
||||
main:has(.analytics-card-tooltip-escape .recharts-tooltip-wrapper[style*="visibility: visible"]) {
|
||||
position: relative;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.analytics-card-tooltip-escape .recharts-tooltip-wrapper > * {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
337
apps/dashboard/src/components/hosted-auth-preview.tsx
Normal file
337
apps/dashboard/src/components/hosted-auth-preview.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { BrandIcons, Button, Input, Label, Separator, Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui";
|
||||
import { KeyIcon } from "@phosphor-icons/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
type HostedPreviewOAuthProvider = {
|
||||
id: string,
|
||||
};
|
||||
|
||||
type HostedAuthPreviewProject = {
|
||||
displayName?: string,
|
||||
config: {
|
||||
signUpEnabled: boolean,
|
||||
credentialEnabled: boolean,
|
||||
passkeyEnabled: boolean,
|
||||
magicLinkEnabled: boolean,
|
||||
oauthProviders: HostedPreviewOAuthProvider[],
|
||||
},
|
||||
};
|
||||
|
||||
type HostedAuthType = "sign-in" | "sign-up";
|
||||
|
||||
const authTabsListClassName = "mb-4 h-10 w-full rounded-lg border border-black/[0.08] bg-zinc-100/70 p-1 dark:border-white/[0.10] dark:bg-zinc-900/45";
|
||||
const authTabsTriggerClassName = "h-8 flex-1 rounded-md py-0 text-sm font-medium text-muted-foreground transition-colors duration-300 hover:text-foreground/90 data-[state=active]:font-semibold data-[state=active]:text-foreground";
|
||||
const authFooterClassName = "mt-6 border-t border-black/[0.06] pt-5 text-center text-sm dark:border-white/[0.10]";
|
||||
const authFooterLinkClassName = "font-medium text-foreground/90 underline-offset-4 transition-colors hover:text-foreground hover:underline";
|
||||
|
||||
const providerButtonClassNames = new Map<string, string>([
|
||||
["google", "bg-white text-black hover:bg-zinc-50 border border-border shadow-sm"],
|
||||
["github", "bg-[#24292e] text-white hover:bg-[#1f2327] border border-[#1b1f23] shadow-sm"],
|
||||
["facebook", "bg-[#1877F2] text-white hover:bg-[#166fe5] border border-[#1464d3] shadow-sm"],
|
||||
["microsoft", "bg-[#2f2f2f] text-white hover:bg-[#252525] border border-[#202020] shadow-sm"],
|
||||
["spotify", "bg-[#1ED760] text-black hover:bg-[#1db954] border border-[#1aa34a] shadow-sm"],
|
||||
["discord", "bg-[#5865F2] text-white hover:bg-[#4752c4] border border-[#3c45b0] shadow-sm"],
|
||||
["apple", "bg-black text-white hover:bg-zinc-900 dark:bg-white dark:text-black dark:hover:bg-zinc-100 border border-zinc-900 dark:border-zinc-200 shadow-sm"],
|
||||
["x", "bg-black text-white hover:bg-zinc-900 dark:bg-white dark:text-black dark:hover:bg-zinc-100 border border-zinc-900 dark:border-zinc-200 shadow-sm"],
|
||||
["gitlab", "bg-[#FC6D26] text-white hover:bg-[#e24329] border border-[#d13b1f] shadow-sm"],
|
||||
["bitbucket", "bg-[#0052CC] text-white hover:bg-[#0047b3] border border-[#003d99] shadow-sm"],
|
||||
["linkedin", "bg-[#0077B5] text-white hover:bg-[#006699] border border-[#005580] shadow-sm"],
|
||||
["twitch", "bg-[#9146FF] text-white hover:bg-[#772ce8] border border-[#641bdf] shadow-sm"],
|
||||
]);
|
||||
|
||||
function getProviderButtonClassName(provider: string) {
|
||||
return providerButtonClassNames.get(provider) ?? "bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent shadow-sm";
|
||||
}
|
||||
|
||||
function getProviderStyle(provider: string): {
|
||||
name: string,
|
||||
icon: ReactElement | null,
|
||||
iconClassName?: string,
|
||||
} {
|
||||
const iconSize = 20;
|
||||
|
||||
switch (provider) {
|
||||
case "google": {
|
||||
return { name: "Google", icon: <BrandIcons.Google iconSize={iconSize} /> };
|
||||
}
|
||||
case "github": {
|
||||
return { name: "GitHub", icon: <BrandIcons.GitHub iconSize={iconSize} />, iconClassName: "invert-0" };
|
||||
}
|
||||
case "facebook": {
|
||||
return { name: "Facebook", icon: <BrandIcons.Facebook iconSize={iconSize} />, iconClassName: "invert-0" };
|
||||
}
|
||||
case "microsoft": {
|
||||
return { name: "Microsoft", icon: <BrandIcons.Microsoft iconSize={iconSize} /> };
|
||||
}
|
||||
case "spotify": {
|
||||
return { name: "Spotify", icon: <BrandIcons.Spotify iconSize={iconSize} />, iconClassName: "invert dark:invert" };
|
||||
}
|
||||
case "discord": {
|
||||
return { name: "Discord", icon: <BrandIcons.Discord iconSize={iconSize} />, iconClassName: "invert-0" };
|
||||
}
|
||||
case "gitlab": {
|
||||
return { name: "GitLab", icon: <BrandIcons.Gitlab iconSize={iconSize} /> };
|
||||
}
|
||||
case "apple": {
|
||||
return { name: "Apple", icon: <BrandIcons.Apple iconSize={iconSize} />, iconClassName: "invert-0 dark:invert" };
|
||||
}
|
||||
case "bitbucket": {
|
||||
return { name: "Bitbucket", icon: <BrandIcons.Bitbucket iconSize={iconSize} /> };
|
||||
}
|
||||
case "linkedin": {
|
||||
return { name: "LinkedIn", icon: <BrandIcons.LinkedIn iconSize={iconSize} />, iconClassName: "invert-0" };
|
||||
}
|
||||
case "x": {
|
||||
return { name: "X", icon: <BrandIcons.X iconSize={iconSize} />, iconClassName: "invert-0 dark:invert" };
|
||||
}
|
||||
case "twitch": {
|
||||
return { name: "Twitch", icon: <BrandIcons.Twitch iconSize={iconSize} />, iconClassName: "invert-0" };
|
||||
}
|
||||
default: {
|
||||
return { name: provider, icon: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewFrame(props: {
|
||||
children: ReactNode,
|
||||
className?: string,
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("auth-preview-host-theme stack-scope flex w-full justify-center", props.className)}>
|
||||
<div className="stack-scope relative z-10 flex w-full max-w-[400px] flex-col items-stretch text-foreground">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedAuthHeading(props: {
|
||||
title: string,
|
||||
children?: ReactNode,
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 text-center">
|
||||
<Typography type="h2" className="mb-1 text-xl font-semibold tracking-tight">{props.title}</Typography>
|
||||
{props.children != null && (
|
||||
<Typography className="text-sm text-muted-foreground">{props.children}</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeparatorWithText(props: {
|
||||
text: string,
|
||||
}) {
|
||||
return (
|
||||
<div className="stack-scope my-6 flex items-center justify-center">
|
||||
<div className="flex-1">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="mx-2 text-sm text-muted-foreground">{props.text}</div>
|
||||
<div className="flex-1">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthPreviewButton(props: {
|
||||
provider: string,
|
||||
type: HostedAuthType,
|
||||
}) {
|
||||
const style = getProviderStyle(props.provider);
|
||||
return (
|
||||
<Button
|
||||
variant="plain"
|
||||
tabIndex={-1}
|
||||
className={cn("stack-scope relative h-10 w-full overflow-visible rounded-xl font-medium transition-all duration-150", getProviderButtonClassName(props.provider))}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<span className={style.iconClassName}>{style.icon}</span>
|
||||
<span className="flex-1 text-sm">
|
||||
{props.type === "sign-up" ? `Sign up with ${style.name}` : `Sign in with ${style.name}`}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthPreviewButtonGroup(props: {
|
||||
providers: HostedPreviewOAuthProvider[],
|
||||
type: HostedAuthType,
|
||||
}) {
|
||||
return (
|
||||
<div className="stack-scope flex flex-col items-stretch gap-3">
|
||||
{props.providers.map((provider) => (
|
||||
<OAuthPreviewButton key={provider.id} provider={provider.id} type={props.type} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyPreviewButton(props: {
|
||||
type: HostedAuthType,
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="plain"
|
||||
tabIndex={-1}
|
||||
className="stack-scope h-10 rounded-xl border border-transparent bg-primary font-medium text-primary-foreground shadow-sm transition-all duration-150 hover:bg-primary/90"
|
||||
>
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<KeyIcon className="size-5" />
|
||||
<span className="flex-1">
|
||||
{props.type === "sign-up" ? "Sign up with Passkey" : "Sign in with Passkey"}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedMagicLinkPreviewForm() {
|
||||
return (
|
||||
<form className="stack-scope flex flex-col items-stretch" noValidate>
|
||||
<Label htmlFor="hosted-preview-email" className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Email</Label>
|
||||
<Input
|
||||
id="hosted-preview-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="h-10 rounded-xl border-border bg-background"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow" tabIndex={-1}>
|
||||
Send email
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCredentialPreviewForm(props: {
|
||||
type: HostedAuthType,
|
||||
}) {
|
||||
return (
|
||||
<form className="stack-scope flex flex-col items-stretch" noValidate>
|
||||
<Label htmlFor="hosted-preview-email-password-email" className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Email</Label>
|
||||
<Input
|
||||
id="hosted-preview-email-password-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="h-10 rounded-xl border-border bg-background"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<div className="mb-1.5 mt-4 flex items-center justify-between">
|
||||
<Label htmlFor="hosted-preview-email-password-password" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Password</Label>
|
||||
{props.type === "sign-in" && (
|
||||
<a href="#" className="text-xs text-muted-foreground hover:text-foreground" tabIndex={-1}>
|
||||
Forgot password?
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="hosted-preview-email-password-password"
|
||||
type="password"
|
||||
autoComplete={props.type === "sign-in" ? "current-password" : "new-password"}
|
||||
className="h-10 rounded-xl border-border bg-background"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow" tabIndex={-1}>
|
||||
{props.type === "sign-in" ? "Sign In" : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostedAuthMethodPreview(props: {
|
||||
project: HostedAuthPreviewProject,
|
||||
type?: HostedAuthType,
|
||||
firstTab?: "magic-link" | "password",
|
||||
className?: string,
|
||||
}) {
|
||||
const type = props.type ?? "sign-in";
|
||||
if (type === "sign-up" && !props.project.config.signUpEnabled) {
|
||||
return (
|
||||
<PreviewFrame className={props.className}>
|
||||
<HostedAuthHeading title="Sign up disabled">
|
||||
New account creation is disabled for this project.
|
||||
</HostedAuthHeading>
|
||||
</PreviewFrame>
|
||||
);
|
||||
}
|
||||
const hasOAuthProviders = props.project.config.oauthProviders.length > 0;
|
||||
const hasPasskey = props.project.config.passkeyEnabled === true && type === "sign-in";
|
||||
const hasEmailMethods = props.project.config.credentialEnabled || props.project.config.magicLinkEnabled;
|
||||
const enableSeparator = hasEmailMethods && (hasOAuthProviders || hasPasskey);
|
||||
|
||||
return (
|
||||
<PreviewFrame className={props.className}>
|
||||
<HostedAuthHeading title={type === "sign-in" ? "Sign in" : "Create account"}>
|
||||
{type === "sign-in" ? (
|
||||
<>
|
||||
to continue to <span className="font-medium text-foreground">{props.project.displayName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
to get started with <span className="font-medium text-foreground">{props.project.displayName}</span>
|
||||
</>
|
||||
)}
|
||||
</HostedAuthHeading>
|
||||
|
||||
{(hasOAuthProviders || hasPasskey) && (
|
||||
<div className="mb-2 flex flex-col items-stretch gap-3">
|
||||
{hasOAuthProviders && <OAuthPreviewButtonGroup providers={props.project.config.oauthProviders} type={type} />}
|
||||
{hasPasskey && <PasskeyPreviewButton type={type} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableSeparator && <SeparatorWithText text="Or continue with" />}
|
||||
|
||||
{props.project.config.credentialEnabled && props.project.config.magicLinkEnabled ? (
|
||||
<Tabs defaultValue={props.firstTab || "magic-link"} className="w-full">
|
||||
<TabsList className={cn(authTabsListClassName, {
|
||||
"flex-row-reverse": props.firstTab === "password",
|
||||
})}>
|
||||
<TabsTrigger value="magic-link" className={authTabsTriggerClassName} tabIndex={-1}>Email</TabsTrigger>
|
||||
<TabsTrigger value="password" className={authTabsTriggerClassName} tabIndex={-1}>Email & Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="magic-link" className="focus-visible:outline-none focus-visible:ring-0">
|
||||
<HostedMagicLinkPreviewForm />
|
||||
</TabsContent>
|
||||
<TabsContent value="password" className="focus-visible:outline-none focus-visible:ring-0">
|
||||
<HostedCredentialPreviewForm type={type} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : props.project.config.credentialEnabled ? (
|
||||
<HostedCredentialPreviewForm type={type} />
|
||||
) : props.project.config.magicLinkEnabled ? (
|
||||
<HostedMagicLinkPreviewForm />
|
||||
) : !(hasOAuthProviders || hasPasskey) ? (
|
||||
<p className="py-4 text-center text-sm text-destructive">No authentication method enabled.</p>
|
||||
) : null}
|
||||
|
||||
{(type === "sign-up" || props.project.config.signUpEnabled) && (
|
||||
<div className={authFooterClassName}>
|
||||
{type === "sign-in" ? (
|
||||
<p className="text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<a href="#" className={authFooterLinkClassName} tabIndex={-1}>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<a href="#" className={authFooterLinkClassName} tabIndex={-1}>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PreviewFrame>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, type TooltipProps } from 'recharts';
|
||||
import { useId } from 'react';
|
||||
|
||||
type DataPoint = { date: string, activity: number };
|
||||
@ -9,7 +9,26 @@ const CHART_HEIGHT = 56;
|
||||
const EMPTY_BASELINE_COUNT = 30;
|
||||
|
||||
function formatDay(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' });
|
||||
// Append T00:00:00 to force local-time interpretation for bare YYYY-MM-DD strings,
|
||||
// which are otherwise parsed as UTC midnight and can shift to the previous day in negative-UTC timezones
|
||||
const date = iso.includes('T') ? new Date(iso) : new Date(`${iso}T00:00:00`);
|
||||
return date.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function ProjectUsersMetricTooltip({ active, label, payload }: TooltipProps<number, string>) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = payload[0]?.value;
|
||||
const formattedValue = typeof value === 'number' ? value.toLocaleString() : value;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-background dark:ring-white/[0.08]">
|
||||
<div className="text-[10px] text-muted-foreground">{formatDay(label)}</div>
|
||||
<div>new signups: {formattedValue}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBaseline({ count }: { count: number }) {
|
||||
@ -111,18 +130,7 @@ export function ProjectUsersMetric(props: {
|
||||
</defs>
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'currentColor', strokeOpacity: 0.2, strokeDasharray: '2 3' }}
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--background))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
boxShadow: '0 6px 20px -10px rgb(0 0 0 / 0.3)',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--muted-foreground))', marginBottom: 1, fontSize: 10 }}
|
||||
itemStyle={{ color: 'hsl(var(--foreground))', padding: 0 }}
|
||||
labelFormatter={(label: string) => formatDay(label)}
|
||||
formatter={(value: number) => [value.toLocaleString(), 'new signups']}
|
||||
content={<ProjectUsersMetricTooltip />}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
|
||||
@ -188,7 +188,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-black/[0.08] bg-white px-2.5 py-1.5 text-xs shadow-xl ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-background dark:ring-white/[0.08]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@ -370,4 +370,3 @@ export {
|
||||
ChartStyle, ChartTooltip,
|
||||
ChartTooltipContent
|
||||
};
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ const Command: React.FC<React.ComponentPropsWithoutRef<typeof CommandPrimitive>>
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
"stack-scope flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-popover-foreground dark:bg-popover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -156,4 +156,3 @@ export {
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
|
||||
@ -86,7 +86,7 @@ const DropdownMenuSubContent = forwardRefIfNeeded<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border/50 bg-popover p-1.5 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] bg-white p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-background/95 dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -270,4 +270,3 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger, DropdownMenuTrigger
|
||||
};
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ const PopoverContent = forwardRefIfNeeded<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 pointer-events-auto w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 pointer-events-auto w-72 rounded-md border border-black/[0.08] bg-white p-4 text-popover-foreground shadow-md ring-1 ring-black/[0.06] outline-none dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -32,4 +32,3 @@ const PopoverContent = forwardRefIfNeeded<
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ const SheetOverlay = forwardRefIfNeeded<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"stack-scope fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-100",
|
||||
"stack-scope fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out dark:bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-100",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
@ -137,4 +137,3 @@ export {
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 overflow-hidden rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -38,4 +38,3 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ function HostedUserButtonInner() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="stack-scope w-64 rounded-xl border border-black/[0.08] bg-popover/95 p-1.5 shadow-lg backdrop-blur-md dark:border-white/[0.08]"
|
||||
className="stack-scope w-64 rounded-xl border border-black/[0.08] bg-white/95 p-1.5 shadow-lg ring-1 ring-black/[0.06] backdrop-blur-md dark:border-white/[0.08] dark:bg-popover/95 dark:ring-white/[0.08]"
|
||||
>
|
||||
<DropdownMenuLabel className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
@ -12,7 +12,7 @@ const Command: React.FC<React.ComponentPropsWithoutRef<typeof CommandPrimitive>>
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground",
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-xl bg-white text-popover-foreground dark:bg-popover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -80,7 +80,7 @@ const DropdownMenuSubContent = forwardRefIfNeeded<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] dark:border-white/[0.10] bg-popover/95 p-1.5 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] bg-white/95 p-1.5 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.10] dark:bg-popover/95 dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -98,7 +98,7 @@ const DropdownMenuContent = forwardRefIfNeeded<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] dark:border-white/[0.10] bg-popover/95 p-1.5 text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] bg-white/95 p-1.5 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.10] dark:bg-popover/95 dark:ring-white/[0.08]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
|
||||
@ -20,7 +20,7 @@ const PopoverContent = forwardRefIfNeeded<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 pointer-events-auto w-72 rounded-xl border border-black/[0.08] dark:border-white/[0.10] bg-popover/95 p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 pointer-events-auto w-72 rounded-xl border border-black/[0.08] bg-white/95 p-4 text-popover-foreground shadow-md ring-1 ring-black/[0.06] outline-none dark:border-white/[0.10] dark:bg-popover/95 dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -79,7 +79,7 @@ const SelectContent = forwardRefIfNeeded<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] dark:border-white/[0.10] bg-popover/95 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] bg-white/95 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.10] dark:bg-popover/95 dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@ -29,7 +29,7 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 overflow-hidden rounded-lg border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -1329,7 +1329,7 @@ export function AnalyticsChart({
|
||||
const days = hi - lo + 1;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute -top-1 z-30 -translate-x-1/2 -translate-y-full rounded-lg border border-blue-500/30 bg-background/95 px-3 py-1.5 text-[11px] shadow-lg backdrop-blur-xl dark:border-blue-400/30"
|
||||
className="pointer-events-none absolute -top-1 z-30 -translate-x-1/2 -translate-y-full rounded-lg border border-blue-500/30 bg-white/95 px-3 py-1.5 text-[11px] shadow-lg backdrop-blur-xl dark:border-blue-400/30 dark:bg-background/95"
|
||||
style={{ left: indexToCss((lo + hi) / 2) }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -1380,7 +1380,7 @@ export function AnalyticsChart({
|
||||
const draft = annotationDraft;
|
||||
return (
|
||||
<div className="absolute top-2 z-30" style={anchorStyle}>
|
||||
<div className="flex items-center gap-1 rounded-full border border-foreground/10 bg-background/95 py-1 pl-2 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center gap-1 rounded-full border border-black/[0.08] bg-white/95 py-1 pl-2 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.18)] ring-1 ring-black/[0.06] backdrop-blur-xl dark:border-white/[0.08] dark:bg-background/95 dark:ring-white/[0.08] dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)]">
|
||||
{draft == null ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap px-1 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
@ -1582,4 +1582,3 @@ export function AnalyticsChart({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ export function DefaultAnalyticsChartTooltip({ ctx }: DefaultAnalyticsChartToolt
|
||||
const { point, isPinned, primary, compare, delta, formatValue: fv, formatDate: fd, strings } = ctx;
|
||||
const anySegmented = (primary?.segmented ?? false) || (compare?.segmented ?? false);
|
||||
return (
|
||||
<div className="rounded-xl border border-foreground/10 bg-background/95 px-3 py-2.5 shadow-[0_10px_28px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)] min-w-[180px]">
|
||||
<div className="rounded-xl border border-black/[0.08] bg-white/95 px-3 py-2.5 shadow-[0_10px_28px_rgba(15,23,42,0.18)] ring-1 ring-black/[0.06] backdrop-blur-xl dark:border-white/[0.08] dark:bg-background/95 dark:ring-white/[0.08] dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)] min-w-[180px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{fd(point.ts)}
|
||||
|
||||
@ -85,7 +85,7 @@ export const DesignChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-xl bg-background/95 px-3.5 py-2.5 text-xs shadow-lg backdrop-blur-xl ring-1 ring-foreground/[0.08]",
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-xl bg-white/95 px-3.5 py-2.5 text-xs shadow-lg ring-1 ring-black/[0.08] backdrop-blur-xl dark:bg-background/95 dark:ring-white/[0.08]",
|
||||
className
|
||||
)}
|
||||
style={{ zIndex: 9999 }}
|
||||
|
||||
@ -60,7 +60,7 @@ function PopoverPanel({
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
"absolute top-full left-0 mt-1 z-50",
|
||||
"bg-popover text-popover-foreground rounded-xl shadow-lg",
|
||||
"bg-white text-popover-foreground rounded-xl shadow-lg dark:bg-popover",
|
||||
"ring-1 ring-black/[0.08] dark:ring-white/[0.1]",
|
||||
"backdrop-blur-xl",
|
||||
className,
|
||||
|
||||
@ -1032,7 +1032,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
>
|
||||
<div
|
||||
ref={stickyChromeRef}
|
||||
className="sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl"
|
||||
className="sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background backdrop-blur-xl"
|
||||
style={{ top: stickyTop ?? (maxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
|
||||
>
|
||||
{toolbar !== false && (
|
||||
@ -1219,7 +1219,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
</div>
|
||||
|
||||
{footer !== false && (
|
||||
<div className="sticky bottom-0 z-30 shrink-0 overflow-hidden rounded-b-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl">
|
||||
<div className="sticky bottom-0 z-30 shrink-0 overflow-hidden rounded-b-[calc(var(--radius)*2)] bg-white/90 dark:bg-background backdrop-blur-xl">
|
||||
{footer ? footer(footerCtx) : <DefaultFooter ctx={footerCtx} pagination={paginationMode} onChange={onChange} />}
|
||||
{footerExtra && (typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra)}
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@ const Command: React.FC<React.ComponentPropsWithoutRef<typeof CommandPrimitive>>
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
"stack-scope flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-popover-foreground dark:bg-popover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -156,4 +156,3 @@ export {
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ const ContextMenuSubContent = forwardRefIfNeeded<
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-lg ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -67,7 +67,7 @@ const ContextMenuContent = forwardRefIfNeeded<
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -42,7 +42,7 @@ const DialogContent = forwardRefIfNeeded<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope fixed left-[50%] top-[50%] max-h-screen z-50 flex flex-col w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg duration-100 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"stack-scope fixed left-[50%] top-[50%] max-h-screen z-50 flex flex-col w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border bg-white p-6 shadow-lg duration-100 dark:bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -132,4 +132,3 @@ export {
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
};
|
||||
|
||||
|
||||
@ -82,7 +82,7 @@ const DropdownMenuSubContent = forwardRefIfNeeded<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-lg ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -100,7 +100,7 @@ const DropdownMenuContent = forwardRefIfNeeded<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"stack-scope z-50 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
|
||||
@ -19,7 +19,7 @@ const HoverCardContent = forwardRefIfNeeded<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 w-64 rounded-md border border-black/[0.08] bg-white p-4 text-popover-foreground shadow-md ring-1 ring-black/[0.06] outline-none dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -79,7 +79,7 @@ const MenubarSubContent = React.forwardRef<
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-lg ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -102,7 +102,7 @@ const MenubarContent = React.forwardRef<
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-black/[0.08] bg-white p-1 text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -89,7 +89,7 @@ const NavigationMenuViewport = forwardRefIfNeeded<
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"stack-scope origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
"stack-scope origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-black/[0.08] bg-white text-popover-foreground shadow ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -22,7 +22,7 @@ const PopoverContent = forwardRefIfNeeded<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 pointer-events-auto w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 pointer-events-auto w-72 rounded-md border border-black/[0.08] bg-white p-4 text-popover-foreground shadow-md ring-1 ring-black/[0.06] outline-none dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -86,7 +86,7 @@ const SelectContent = forwardRefIfNeeded<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-black/[0.08] bg-white text-popover-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-popover dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
@ -159,4 +159,3 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
export {
|
||||
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
|
||||
};
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ const SheetOverlay = forwardRefIfNeeded<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"stack-scope fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-100",
|
||||
"stack-scope fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out dark:bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-100",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@ -31,7 +31,7 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"stack-scope z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope z-50 overflow-hidden rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user