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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 20 additions & 33 deletions apps/web/src/components/public/public-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { ReactNode } from "react";

import { CustomCss } from "@/components/branding/custom-css";
import { ThemeSwitch } from "@/components/dashboard-kit/theme-switch";
import { SignOut } from "@/components/icons";
import { DiscordMark } from "@/components/icons/brand-marks";
import { accountCopy, dashboardLinks } from "@/components/site/account";
import { AccountMenu } from "@/components/site/account-menu";
import { LanguagePicker } from "@/components/site/language-picker";
import { getCurrentUser } from "@/lib/auth";
import { logoutAction } from "@/lib/auth-actions";
import { brandProps, logoUrl } from "@/lib/branding";
import { requestHostname } from "@/lib/custom-domain";
import { getDict, getLocale } from "@/i18n";

import { PoweredBy } from "./powered-by";
Expand All @@ -25,7 +26,8 @@ export interface PublicGuild {
* controls, focus); the colour is the customer's, with a readable fallback
* per theme (lib/branding.ts). No msk-scripts.de navigation: the page belongs
* to the guild, and only a small "Powered by" line on the Free plan says who
* runs it.
* runs it. A signed-in visitor gets the site's account menu, so the way into
* the dashboard is the same here as on the site pages.
*
* Three layers, each for a reason:
* - the outer wrapper carries the accent (`brandProps`), so the bar, the
Expand All @@ -51,7 +53,12 @@ export async function PublicFrame({
poweredBy?: boolean;
children: ReactNode;
}) {
const [dict, locale, user] = await Promise.all([getDict(), getLocale(), getCurrentUser()]);
const [dict, locale, user, host] = await Promise.all([
getDict(),
getLocale(),
getCurrentUser(),
requestHostname(),
]);
const logo = logoUrl(guild.id, guild.branding);
// Same-origin login link on every host. The login route is host-aware: on
// a custom domain with its own Discord OAuth app it runs the whole flow
Expand Down Expand Up @@ -90,35 +97,15 @@ export async function PublicFrame({
className="hidden sm:inline-flex"
/>
{user ? (
<form action={logoutAction} className="flex items-center gap-2">
{user.avatar && (
<img
src={user.avatar}
alt=""
width={28}
height={28}
className="hidden h-7 w-7 rounded-full sm:block"
/>
)}
<span className="sr-only">
{dict.site.signedInAs.replace("{name}", user.username)}
</span>
<span
aria-hidden
translate="no"
className="hidden max-w-[10rem] truncate text-small font-semibold sm:inline"
>
{user.username}
</span>
<button
type="submit"
aria-label={dict.header.logout}
title={dict.header.logout}
className="inline-flex h-11 w-11 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<SignOut className="h-5 w-5" />
</button>
</form>
<AccountMenu
account={{
user: { username: user.username, avatar: user.avatar },
loginHref,
...dashboardLinks(host),
}}
copy={accountCopy(dict)}
hideNameOnPhone
/>
) : (
<a
href={loginHref}
Expand Down
179 changes: 179 additions & 0 deletions apps/web/src/components/site/account-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"use client";

import { useCallback, useId, useRef, useState } from "react";

import { useDismiss } from "@/components/dashboard-kit/use-dismiss";
import { ThemeSwitch, type ThemeSwitchLabels } from "@/components/dashboard-kit/theme-switch";
import { CaretDown, SignOut, User } from "@/components/icons";
import { DiscordMark } from "@/components/icons/brand-marks";
import { Button } from "@/components/ui/button";
import { logoutAction } from "@/lib/auth-actions";
import { cn } from "@/lib/utils";

export interface AccountCopy {
account: string;
login: string;
signIn: string;
signedInAs: string;
dashboard: string;
mySubmissions: string;
logout: string;
theme: ThemeSwitchLabels;
}

export interface HeaderAccount {
user: { username: string; avatar: string | null } | null;
loginHref: string;
dashboardHref: string;
mySubmissionsHref: string;
}

/**
* What the account entry holds, in the header panel and in the menu: the
* Discord sign-in, or who is signed in with the way to the dashboard, and the
* appearance switch.
*/
export function AccountSections({
account,
copy,
onNavigate,
}: {
account: HeaderAccount;
copy: AccountCopy;
onNavigate?: () => void;
}) {
// Rendered twice on the site pages (header panel and menu), so the label
// ids must differ.
const id = useId();
const { user } = account;
return (
<div className="grid gap-5">
<div role="group" aria-labelledby={`${id}-account`} className="grid gap-2">
<p id={`${id}-account`} className="kicker">
{copy.account}
</p>
{user ? (
<>
<p className="text-small text-muted-foreground">
{copy.signedInAs.replace("{name}", user.username)}
</p>
<ul className="grid">
{[
{ href: account.dashboardHref, label: copy.dashboard },
{ href: account.mySubmissionsHref, label: copy.mySubmissions },
].map((l) => (
<li key={l.href}>
<a
href={l.href}
onClick={onNavigate}
className="flex min-h-11 items-center rounded-md px-3 font-medium text-foreground transition-colors duration-150 hover:bg-muted hover:text-primary"
>
{l.label}
</a>
</li>
))}
</ul>
<form action={logoutAction}>
<Button type="submit" variant="outline" className="w-full justify-start">
<SignOut className="h-4 w-4" />
{copy.logout}
</Button>
</form>
</>
) : (
<Button asChild variant="discord" className="justify-start">
<a href={account.loginHref}>
<DiscordMark className="h-4 w-4" />
{copy.signIn}
</a>
</Button>
)}
</div>

<div role="group" aria-labelledby={`${id}-theme`} className="grid gap-2">
<p id={`${id}-theme`} className="kicker">
{copy.theme.group}
</p>
<ThemeSwitch labels={copy.theme} />
</div>
</div>
);
}

/**
* The account entry of a header: "Log in" or the Discord name, with the panel
* below. Used by the site header and by the header of a guild's public pages,
* so both offer the same way into the dashboard.
*/
export function AccountMenu({
account,
copy,
hideNameOnPhone = false,
className,
}: {
account: HeaderAccount;
copy: AccountCopy;
/** Below 640 px show only the avatar, so a long name next to it keeps its room. */
hideNameOnPhone?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
const close = useCallback((reason: "outside" | "escape") => {
setOpen(false);
if (reason === "escape") buttonRef.current?.focus();
}, []);
useDismiss(open, ref, close);
const { user } = account;

return (
<div className={cn("relative", className)} ref={ref}>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className="inline-flex min-h-11 max-w-[14rem] items-center gap-1.5 rounded-md px-2 font-medium text-foreground transition-colors duration-150 hover:text-primary"
>
{user?.avatar ? (
<img
src={user.avatar}
alt=""
width={24}
height={24}
className="h-6 w-6 shrink-0 rounded-full"
/>
) : (
<User className="h-5 w-5 shrink-0" />
)}
{user ? (
<>
<span className="sr-only">{copy.account}: </span>
<span translate="no" className={cn("truncate", hideNameOnPhone && "max-sm:sr-only")}>
{user.username}
</span>
</>
) : (
<span>{copy.login}</span>
)}
<CaretDown
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-150",
open && "rotate-180",
)}
/>
</button>

<div
id={panelId}
hidden={!open}
className="absolute top-full right-0 z-[60] mt-2 w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-border bg-card p-4 shadow-[var(--shadow-card-hover)]"
>
<AccountSections account={account} copy={copy} onNavigate={() => setOpen(false)} />
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions apps/web/src/components/site/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Dictionary } from "@/i18n";
import { isPrimaryHostname } from "@/lib/custom-domain";
import { appBaseUrl } from "@/lib/url";

import type { AccountCopy } from "./account-menu";

/** The account entry's copy, the same in the site header and on a guild's public pages. */
export function accountCopy(dict: Dictionary): AccountCopy {
return {
account: dict.site.account,
login: dict.header.login,
signIn: dict.hero.loginDiscord,
signedInAs: dict.site.signedInAs,
dashboard: dict.header.dashboard,
mySubmissions: dict.dashboard.mySubmissions,
logout: dict.header.logout,
theme: dict.kit.shell.theme,
};
}

/**
* The account entry's way into the dashboard. The dashboard lives only on the
* primary domain, so from a custom domain the links lead there.
*/
export function dashboardLinks(host: string | null): {
dashboardHref: string;
mySubmissionsHref: string;
} {
const base = host && !isPrimaryHostname(host) ? appBaseUrl() : "";
return { dashboardHref: `${base}/dashboard`, mySubmissionsHref: `${base}/dashboard/me` };
}
Loading
Loading