first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
28
apps/web/modules/shared/components/ApiClientProvider.tsx
Normal file
28
apps/web/modules/shared/components/ApiClientProvider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { createQueryClient } from "@shared/lib/query-client";
|
||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
let clientQueryClientSingleton: QueryClient;
|
||||
function getQueryClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return createQueryClient();
|
||||
}
|
||||
|
||||
if (!clientQueryClientSingleton) {
|
||||
clientQueryClientSingleton = createQueryClient();
|
||||
}
|
||||
|
||||
return clientQueryClientSingleton;
|
||||
}
|
||||
|
||||
export function ApiClientProvider({ children }: PropsWithChildren) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
82
apps/web/modules/shared/components/ColorModeToggle.tsx
Normal file
82
apps/web/modules/shared/components/ColorModeToggle.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { HardDriveIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
export function ColorModeToggle() {
|
||||
const { resolvedTheme, setTheme, theme } = useTheme();
|
||||
const [value, setValue] = useState<string>(theme ?? "system");
|
||||
const isClient = useIsClient();
|
||||
|
||||
const colorModeOptions = [
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: HardDriveIcon,
|
||||
},
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: SunIcon,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: MoonIcon,
|
||||
},
|
||||
];
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-test="color-mode-toggle"
|
||||
aria-label="Color mode"
|
||||
>
|
||||
{resolvedTheme === "light" ? (
|
||||
<SunIcon className="size-4" />
|
||||
) : (
|
||||
<MoonIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
setTheme(value);
|
||||
setValue(value);
|
||||
}}
|
||||
>
|
||||
{colorModeOptions.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
data-test={`color-mode-toggle-item-${option.value}`}
|
||||
>
|
||||
<option.icon className="mr-2 size-4 opacity-50" />{" "}
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
52
apps/web/modules/shared/components/ConsentBanner.tsx
Normal file
52
apps/web/modules/shared/components/ConsentBanner.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useCookieConsent } from "@shared/hooks/cookie-consent";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { CookieIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ConsentBanner() {
|
||||
const { userHasConsented, allowCookies, declineCookies } =
|
||||
useCookieConsent();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userHasConsented) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-4 bottom-4 max-w-md z-50">
|
||||
<div className="flex gap-4 rounded-2xl border bg-card p-4 text-card-foreground shadow-xl">
|
||||
<CookieIcon className="block size-6 shrink-0 text-5xl text-primary/60 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm leading-normal">
|
||||
This site doesn't use cookies yet, but we added this
|
||||
banner to demo it to you.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="light"
|
||||
className="flex-1"
|
||||
onClick={() => declineCookies()}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => allowCookies()}
|
||||
>
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
apps/web/modules/shared/components/ConsentProvider.tsx
Normal file
42
apps/web/modules/shared/components/ConsentProvider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
export const ConsentContext = createContext<{
|
||||
userHasConsented: boolean;
|
||||
allowCookies: () => void;
|
||||
declineCookies: () => void;
|
||||
}>({
|
||||
userHasConsented: false,
|
||||
allowCookies: () => {},
|
||||
declineCookies: () => {},
|
||||
});
|
||||
|
||||
export function ConsentProvider({
|
||||
children,
|
||||
initialConsent,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialConsent?: boolean;
|
||||
}) {
|
||||
const [userHasConsented, setUserHasConsented] = useState(!!initialConsent);
|
||||
|
||||
const allowCookies = () => {
|
||||
Cookies.set("consent", "true", { expires: 30 });
|
||||
setUserHasConsented(true);
|
||||
};
|
||||
|
||||
const declineCookies = () => {
|
||||
Cookies.set("consent", "false", { expires: 30 });
|
||||
setUserHasConsented(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConsentContext.Provider
|
||||
value={{ userHasConsented, allowCookies, declineCookies }}
|
||||
>
|
||||
{children}
|
||||
</ConsentContext.Provider>
|
||||
);
|
||||
}
|
||||
58
apps/web/modules/shared/components/Document.tsx
Normal file
58
apps/web/modules/shared/components/Document.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { AnalyticsScript } from "@analytics";
|
||||
import { config } from "@repo/config";
|
||||
import { ApiClientProvider } from "@shared/components/ApiClientProvider";
|
||||
import { ConsentBanner } from "@shared/components/ConsentBanner";
|
||||
import { ConsentProvider } from "@shared/components/ConsentProvider";
|
||||
import { Toaster } from "@ui/components/toast";
|
||||
import { cn } from "@ui/lib";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { cookies } from "next/headers";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export async function Document({
|
||||
children,
|
||||
locale,
|
||||
}: PropsWithChildren<{ locale: string }>) {
|
||||
const cookieStore = await cookies();
|
||||
const consentCookie = cookieStore.get("consent");
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
suppressHydrationWarning
|
||||
className={GeistSans.variable}
|
||||
>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background text-foreground antialiased",
|
||||
)}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<ConsentProvider
|
||||
initialConsent={consentCookie?.value === "true"}
|
||||
>
|
||||
<NextTopLoader color="var(--color-primary)" />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem
|
||||
defaultTheme={config.ui.defaultTheme}
|
||||
themes={config.ui.enabledThemes}
|
||||
>
|
||||
<ApiClientProvider>
|
||||
<JotaiProvider>{children}</JotaiProvider>
|
||||
</ApiClientProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster position="top-right" />
|
||||
<ConsentBanner />
|
||||
<AnalyticsScript />
|
||||
</ConsentProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
72
apps/web/modules/shared/components/LocaleSwitch.tsx
Normal file
72
apps/web/modules/shared/components/LocaleSwitch.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { updateLocale } from "@i18n/lib/update-locale";
|
||||
import { useLocalePathname, useLocaleRouter } from "@i18n/routing";
|
||||
import { config } from "@repo/config";
|
||||
import type { Locale } from "@repo/i18n";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { LanguagesIcon } from "lucide-react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
const { locales } = config.i18n;
|
||||
|
||||
export function LocaleSwitch({
|
||||
withLocaleInUrl = true,
|
||||
}: {
|
||||
withLocaleInUrl?: boolean;
|
||||
}) {
|
||||
const localeRouter = useLocaleRouter();
|
||||
const localePathname = useLocalePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const currentLocale = useLocale();
|
||||
const [value, setValue] = useState<string>(currentLocale);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Language">
|
||||
<LanguagesIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
setValue(value);
|
||||
|
||||
if (withLocaleInUrl) {
|
||||
localeRouter.replace(
|
||||
`${localePathname}?${searchParams.toString()}`,
|
||||
{
|
||||
locale: value,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updateLocale(value as Locale);
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.entries(locales).map(([locale, { label }]) => {
|
||||
return (
|
||||
<DropdownMenuRadioItem key={locale} value={locale}>
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
41
apps/web/modules/shared/components/Logo.tsx
Normal file
41
apps/web/modules/shared/components/Logo.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { cn } from "@ui/lib";
|
||||
|
||||
export function Logo({
|
||||
withLabel = true,
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
withLabel?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center font-semibold text-foreground leading-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg className="size-10 text-primary" viewBox="0 0 734 635">
|
||||
<title>acme</title>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M282.102 232.435C328.904 205.42 404.785 205.42 451.588 232.435L697.946 374.634C744.748 401.648 744.748 445.447 697.946 472.462L451.588 614.661C404.785 641.676 328.904 641.676 282.102 614.661L35.7432 472.462C-11.059 445.447 -11.0589 401.648 35.7432 374.634L282.102 232.435Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M282.102 126.674C328.904 99.66 404.785 99.66 451.588 126.674L697.946 268.874C744.748 295.888 744.748 339.687 697.946 366.702L451.588 508.901C404.785 535.915 328.904 535.915 282.102 508.901L35.7432 366.702C-11.059 339.687 -11.0589 295.888 35.7432 268.874L282.102 126.674Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M451.588 20.9141C404.785 -6.10027 328.904 -6.1003 282.102 20.9141L35.7432 163.113C-11.0589 190.128 -11.059 233.927 35.7432 260.941L282.102 403.141C328.904 430.155 404.785 430.155 451.588 403.141L697.946 260.941C744.748 233.927 744.748 190.128 697.946 163.113L451.588 20.9141ZM497.704 114.921C499.134 115.855 500.121 117.04 500.545 118.332C505.138 132.238 505.138 143.12 505.072 154.003C505.072 198.349 468.453 225.167 420.48 245.161V290.25C420.485 294.097 418.849 297.868 415.755 301.141C412.662 304.413 408.233 307.058 402.967 308.777L337.739 330.105C335.32 330.893 332.634 331.263 329.935 331.181C327.236 331.1 324.613 330.569 322.316 329.64C320.019 328.71 318.124 327.412 316.809 325.87C315.495 324.327 314.806 322.591 314.806 320.825V275.982L299.957 285.686C297.993 286.969 295.661 287.987 293.095 288.682C290.529 289.377 287.779 289.734 285.001 289.734C282.223 289.734 279.473 289.377 276.907 288.682C274.341 287.987 272.009 286.969 270.045 285.686L236.407 263.7C232.442 261.109 230.214 257.594 230.214 253.93C230.214 250.265 232.442 246.751 236.407 244.159L251.257 234.456H182.678C179.975 234.457 177.316 234.006 174.955 233.147C172.593 232.288 170.607 231.049 169.184 229.547C167.761 228.046 166.949 226.331 166.825 224.567C166.701 222.803 167.269 221.047 168.475 219.466L201.136 176.8C203.771 173.364 207.817 170.475 212.82 168.455C217.823 166.435 223.587 165.364 229.468 165.36H298.331C328.857 133.922 369.765 110.084 437.967 110.084C454.555 110.084 471.202 110.084 492.483 113.064C494.46 113.341 496.273 113.986 497.704 114.921ZM405.86 179.723C410.207 181.621 415.318 182.634 420.546 182.634C427.557 182.634 434.281 180.814 439.239 177.575C444.196 174.335 446.981 169.942 446.981 165.36C446.981 161.944 445.431 158.604 442.526 155.763C439.622 152.923 435.493 150.709 430.663 149.401C425.832 148.094 420.517 147.752 415.389 148.418C410.261 149.085 405.551 150.73 401.854 153.146C398.157 155.562 395.639 158.64 394.619 161.99C393.599 165.341 394.123 168.814 396.124 171.971C398.124 175.127 401.513 177.825 405.86 179.723Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{withLabel && (
|
||||
<span className="ml-3 hidden text-lg md:block">acme</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
10
apps/web/modules/shared/components/Spinner.tsx
Normal file
10
apps/web/modules/shared/components/Spinner.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { cn } from "@ui/lib";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
className={cn("size-4 animate-spin text-primary", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
apps/web/modules/shared/components/UserAvatar.tsx
Normal file
43
apps/web/modules/shared/components/UserAvatar.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { config } from "@repo/config";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
|
||||
export const UserAvatar = forwardRef<
|
||||
HTMLSpanElement,
|
||||
{
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
>(({ name, avatarUrl, className }, ref) => {
|
||||
const initials = useMemo(
|
||||
() =>
|
||||
name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join(""),
|
||||
[name],
|
||||
);
|
||||
|
||||
const avatarSrc = useMemo(
|
||||
() =>
|
||||
avatarUrl
|
||||
? avatarUrl.startsWith("http")
|
||||
? avatarUrl
|
||||
: `/image-proxy/${config.storage.bucketNames.avatars}/${avatarUrl}`
|
||||
: undefined,
|
||||
[avatarUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<Avatar ref={ref} className={className}>
|
||||
<AvatarImage src={avatarSrc} />
|
||||
<AvatarFallback className="bg-secondary/10 text-secondary">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
});
|
||||
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
Loading…
Add table
Add a link
Reference in a new issue