@majapisoftwares/ui 6.2.0 → 6.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/patterns/copy-input.tsx +65 -0
- package/src/patterns/details-sheet.tsx +54 -54
- package/src/patterns/preferences.tsx +201 -180
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentProps } from "react";
|
|
4
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import {
|
|
7
|
+
InputGroup,
|
|
8
|
+
InputGroupAddon,
|
|
9
|
+
InputGroupButton,
|
|
10
|
+
InputGroupInput,
|
|
11
|
+
} from "../components/ui/input-group";
|
|
12
|
+
|
|
13
|
+
export interface CopyInputProps
|
|
14
|
+
extends Omit<ComponentProps<typeof InputGroupInput>, "value" | "readOnly"> {
|
|
15
|
+
value: string;
|
|
16
|
+
copyLabel?: string;
|
|
17
|
+
copiedLabel?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function CopyInput({
|
|
22
|
+
value,
|
|
23
|
+
copyLabel = "Copy",
|
|
24
|
+
copiedLabel = "Copied",
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: CopyInputProps) {
|
|
28
|
+
const [copied, setCopied] = useState(false);
|
|
29
|
+
|
|
30
|
+
const handleCopy = async () => {
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(value);
|
|
33
|
+
setCopied(true);
|
|
34
|
+
setTimeout(() => setCopied(false), 2000);
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore clipboard error
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<InputGroup className={cn("w-full min-w-0", className)}>
|
|
42
|
+
<InputGroupInput
|
|
43
|
+
readOnly
|
|
44
|
+
value={value}
|
|
45
|
+
className="font-mono text-xs selection:bg-primary/20"
|
|
46
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
<InputGroupAddon align="inline-end">
|
|
50
|
+
<InputGroupButton
|
|
51
|
+
size="icon-xs"
|
|
52
|
+
onClick={() => void handleCopy()}
|
|
53
|
+
aria-label={copied ? copiedLabel : copyLabel}
|
|
54
|
+
title={copied ? copiedLabel : copyLabel}
|
|
55
|
+
>
|
|
56
|
+
{copied ? (
|
|
57
|
+
<CheckIcon className="size-3.5 text-success-foreground" aria-hidden="true" />
|
|
58
|
+
) : (
|
|
59
|
+
<CopyIcon className="size-3.5" aria-hidden="true" />
|
|
60
|
+
)}
|
|
61
|
+
</InputGroupButton>
|
|
62
|
+
</InputGroupAddon>
|
|
63
|
+
</InputGroup>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
5
|
-
Sheet,
|
|
6
|
-
SheetContent,
|
|
7
|
-
SheetHeader,
|
|
8
|
-
SheetTitle,
|
|
9
|
-
SheetDescription,
|
|
10
|
-
} from "../components/ui/sheet";
|
|
11
|
-
export function DetailsSheet({
|
|
12
|
-
open,
|
|
13
|
-
onOpenChange,
|
|
14
|
-
title,
|
|
15
|
-
description,
|
|
16
|
-
children,
|
|
17
|
-
}: {
|
|
18
|
-
open: boolean;
|
|
19
|
-
onOpenChange: (open: boolean) => void;
|
|
20
|
-
title: string;
|
|
21
|
-
description?: string;
|
|
22
|
-
children: ReactNode;
|
|
23
|
-
}) {
|
|
24
|
-
return (
|
|
25
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
26
|
-
<SheetContent className="overflow-y-auto">
|
|
27
|
-
<SheetHeader>
|
|
28
|
-
<SheetTitle>{title}</SheetTitle>
|
|
29
|
-
{description ? (
|
|
30
|
-
<SheetDescription>{description}</SheetDescription>
|
|
31
|
-
) : null}
|
|
32
|
-
</SheetHeader>
|
|
33
|
-
{/* Same inset as SheetHeader (p-6). */}
|
|
34
|
-
<div className="px-6 pb-8">{children}</div>
|
|
35
|
-
</SheetContent>
|
|
36
|
-
</Sheet>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
export function DetailList({
|
|
40
|
-
items,
|
|
41
|
-
}: {
|
|
42
|
-
items: { label: string; value: ReactNode }[];
|
|
43
|
-
}) {
|
|
44
|
-
return (
|
|
45
|
-
<dl className="grid gap-4 text-sm">
|
|
46
|
-
{items.map((item) => (
|
|
47
|
-
<div key={item.label} className="grid gap-1">
|
|
48
|
-
<dt className="text-muted-foreground">{item.label}</dt>
|
|
49
|
-
<dd className="font-medium break-words">{item.value}</dd>
|
|
50
|
-
</div>
|
|
51
|
-
))}
|
|
52
|
-
</dl>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Sheet,
|
|
6
|
+
SheetContent,
|
|
7
|
+
SheetHeader,
|
|
8
|
+
SheetTitle,
|
|
9
|
+
SheetDescription,
|
|
10
|
+
} from "../components/ui/sheet";
|
|
11
|
+
export function DetailsSheet({
|
|
12
|
+
open,
|
|
13
|
+
onOpenChange,
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
26
|
+
<SheetContent className="overflow-x-hidden overflow-y-auto data-[side=left]:sm:max-w-xl data-[side=right]:sm:max-w-xl">
|
|
27
|
+
<SheetHeader>
|
|
28
|
+
<SheetTitle>{title}</SheetTitle>
|
|
29
|
+
{description ? (
|
|
30
|
+
<SheetDescription>{description}</SheetDescription>
|
|
31
|
+
) : null}
|
|
32
|
+
</SheetHeader>
|
|
33
|
+
{/* Same inset as SheetHeader (p-6). */}
|
|
34
|
+
<div className="min-w-0 px-6 pb-8">{children}</div>
|
|
35
|
+
</SheetContent>
|
|
36
|
+
</Sheet>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
export function DetailList({
|
|
40
|
+
items,
|
|
41
|
+
}: {
|
|
42
|
+
items: { label: string; value: ReactNode }[];
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<dl className="grid min-w-0 gap-4 text-sm">
|
|
46
|
+
{items.map((item) => (
|
|
47
|
+
<div key={item.label} className="grid min-w-0 gap-1">
|
|
48
|
+
<dt className="text-muted-foreground">{item.label}</dt>
|
|
49
|
+
<dd className="min-w-0 font-medium break-words">{item.value}</dd>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</dl>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -1,180 +1,201 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useState,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
11
|
-
import {
|
|
12
|
-
LocaleProvider,
|
|
13
|
-
locales,
|
|
14
|
-
matchLocale,
|
|
15
|
-
useUiMessages,
|
|
16
|
-
type Locale,
|
|
17
|
-
} from "../lib/i18n";
|
|
18
|
-
import { Button } from "../components/ui/button";
|
|
19
|
-
import { Toaster } from "../components/ui/sonner";
|
|
20
|
-
import {
|
|
21
|
-
DropdownMenu,
|
|
22
|
-
DropdownMenuContent,
|
|
23
|
-
DropdownMenuRadioGroup,
|
|
24
|
-
DropdownMenuRadioItem,
|
|
25
|
-
DropdownMenuTrigger,
|
|
26
|
-
} from "../components/ui/dropdown-menu";
|
|
27
|
-
|
|
28
|
-
type Theme = "light" | "dark";
|
|
29
|
-
const PreferencesContext = createContext<{
|
|
30
|
-
locale: Locale;
|
|
31
|
-
setLocale: (locale: Locale) => void;
|
|
32
|
-
theme: Theme;
|
|
33
|
-
setTheme: (theme: Theme) => void;
|
|
34
|
-
} | null>(null);
|
|
35
|
-
const PreferencesReadyContext = createContext(true);
|
|
36
|
-
|
|
37
|
-
function read(key: string) {
|
|
38
|
-
try {
|
|
39
|
-
return localStorage.getItem(key);
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
function write(key: string, value: string) {
|
|
45
|
-
try {
|
|
46
|
-
localStorage.setItem(key, value);
|
|
47
|
-
} catch {
|
|
48
|
-
// Storage can be unavailable (private mode); preferences just won't persist.
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Mirrors matchLocale; keep both in sync.
|
|
54
|
-
* Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
|
|
55
|
-
* stored theme applies before paint.
|
|
56
|
-
*/
|
|
57
|
-
export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Locale + theme for the whole app. Renders `defaultLocale` first (matches
|
|
61
|
-
* static/server HTML), then applies the stored or browser locale on mount.
|
|
62
|
-
*/
|
|
63
|
-
export function Preferences({
|
|
64
|
-
defaultLocale = "en",
|
|
65
|
-
children,
|
|
66
|
-
}: {
|
|
67
|
-
defaultLocale?: Locale;
|
|
68
|
-
children: ReactNode;
|
|
69
|
-
}) {
|
|
70
|
-
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
|
71
|
-
const [theme, setTheme] = useState<Theme>("light");
|
|
72
|
-
const [ready, setReady] = useState(false);
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
const stored = read("locale");
|
|
75
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
|
|
76
|
-
setLocale(
|
|
77
|
-
locales.includes(stored as Locale)
|
|
78
|
-
? (stored as Locale)
|
|
79
|
-
: matchLocale(navigator.languages),
|
|
80
|
-
);
|
|
81
|
-
setTheme(read("theme") === "dark" ? "dark" : "light");
|
|
82
|
-
setReady(true);
|
|
83
|
-
}, []);
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!ready) return;
|
|
86
|
-
const root = document.documentElement;
|
|
87
|
-
root.lang = locale;
|
|
88
|
-
// The pre-paint script hides the page until the saved locale is rendered.
|
|
89
|
-
if (root.dataset.localePending === locale)
|
|
90
|
-
delete root.dataset.localePending;
|
|
91
|
-
}, [locale, ready]);
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
export function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
LocaleProvider,
|
|
13
|
+
locales,
|
|
14
|
+
matchLocale,
|
|
15
|
+
useUiMessages,
|
|
16
|
+
type Locale,
|
|
17
|
+
} from "../lib/i18n";
|
|
18
|
+
import { Button } from "../components/ui/button";
|
|
19
|
+
import { Toaster } from "../components/ui/sonner";
|
|
20
|
+
import {
|
|
21
|
+
DropdownMenu,
|
|
22
|
+
DropdownMenuContent,
|
|
23
|
+
DropdownMenuRadioGroup,
|
|
24
|
+
DropdownMenuRadioItem,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "../components/ui/dropdown-menu";
|
|
27
|
+
|
|
28
|
+
type Theme = "light" | "dark";
|
|
29
|
+
const PreferencesContext = createContext<{
|
|
30
|
+
locale: Locale;
|
|
31
|
+
setLocale: (locale: Locale) => void;
|
|
32
|
+
theme: Theme;
|
|
33
|
+
setTheme: (theme: Theme) => void;
|
|
34
|
+
} | null>(null);
|
|
35
|
+
const PreferencesReadyContext = createContext(true);
|
|
36
|
+
|
|
37
|
+
function read(key: string) {
|
|
38
|
+
try {
|
|
39
|
+
return localStorage.getItem(key);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function write(key: string, value: string) {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(key, value);
|
|
47
|
+
} catch {
|
|
48
|
+
// Storage can be unavailable (private mode); preferences just won't persist.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mirrors matchLocale; keep both in sync.
|
|
54
|
+
* Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
|
|
55
|
+
* stored theme applies before paint.
|
|
56
|
+
*/
|
|
57
|
+
export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locale + theme for the whole app. Renders `defaultLocale` first (matches
|
|
61
|
+
* static/server HTML), then applies the stored or browser locale on mount.
|
|
62
|
+
*/
|
|
63
|
+
export function Preferences({
|
|
64
|
+
defaultLocale = "en",
|
|
65
|
+
children,
|
|
66
|
+
}: {
|
|
67
|
+
defaultLocale?: Locale;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
}) {
|
|
70
|
+
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
|
71
|
+
const [theme, setTheme] = useState<Theme>("light");
|
|
72
|
+
const [ready, setReady] = useState(false);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const stored = read("locale");
|
|
75
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
|
|
76
|
+
setLocale(
|
|
77
|
+
locales.includes(stored as Locale)
|
|
78
|
+
? (stored as Locale)
|
|
79
|
+
: matchLocale(navigator.languages),
|
|
80
|
+
);
|
|
81
|
+
setTheme(read("theme") === "dark" ? "dark" : "light");
|
|
82
|
+
setReady(true);
|
|
83
|
+
}, []);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!ready) return;
|
|
86
|
+
const root = document.documentElement;
|
|
87
|
+
root.lang = locale;
|
|
88
|
+
// The pre-paint script hides the page until the saved locale is rendered.
|
|
89
|
+
if (root.dataset.localePending === locale)
|
|
90
|
+
delete root.dataset.localePending;
|
|
91
|
+
}, [locale, ready]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!ready) return;
|
|
94
|
+
document.documentElement.classList.toggle("dark", theme === "dark");
|
|
95
|
+
}, [theme, ready]);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (
|
|
98
|
+
typeof location === "undefined" ||
|
|
99
|
+
!location.hostname.endsWith(".localhost")
|
|
100
|
+
)
|
|
101
|
+
return;
|
|
102
|
+
const link = document.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
|
103
|
+
const href = link?.getAttribute("href");
|
|
104
|
+
if (!link || !href || href.startsWith("data:")) return;
|
|
105
|
+
void fetch(href)
|
|
106
|
+
.then((res) => (res.ok ? res.text() : ""))
|
|
107
|
+
.then((svg) => {
|
|
108
|
+
if (!svg) return;
|
|
109
|
+
const embeddedPng = svg.match(/data:image\/png;base64,[^"']+/);
|
|
110
|
+
link.href = embeddedPng
|
|
111
|
+
? embeddedPng[0]
|
|
112
|
+
: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
}, []);
|
|
116
|
+
return (
|
|
117
|
+
<PreferencesContext.Provider
|
|
118
|
+
value={{
|
|
119
|
+
locale,
|
|
120
|
+
setLocale: (next) => {
|
|
121
|
+
setLocale(next);
|
|
122
|
+
write("locale", next);
|
|
123
|
+
},
|
|
124
|
+
theme,
|
|
125
|
+
setTheme: (next) => {
|
|
126
|
+
setTheme(next);
|
|
127
|
+
write("theme", next);
|
|
128
|
+
},
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<PreferencesReadyContext.Provider value={ready}>
|
|
132
|
+
<LocaleProvider locale={locale}>
|
|
133
|
+
{children}
|
|
134
|
+
<Toaster theme={theme} />
|
|
135
|
+
</LocaleProvider>
|
|
136
|
+
</PreferencesReadyContext.Provider>
|
|
137
|
+
</PreferencesContext.Provider>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Owns `document.title` so it follows the active language. Leave `title` out
|
|
143
|
+
* of Next.js metadata, or Next will overwrite it after hydration.
|
|
144
|
+
*/
|
|
145
|
+
export function useDocumentTitle(title: string) {
|
|
146
|
+
const ready = useContext(PreferencesReadyContext);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!ready) return;
|
|
149
|
+
document.title = title;
|
|
150
|
+
}, [title, ready]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function usePreferences() {
|
|
154
|
+
const context = useContext(PreferencesContext);
|
|
155
|
+
if (!context)
|
|
156
|
+
throw new Error("usePreferences must be used within Preferences.");
|
|
157
|
+
return context;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const localeNames: Record<Locale, string> = {
|
|
161
|
+
en: "English",
|
|
162
|
+
"pt-BR": "Português",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Language menu and theme toggle, e.g. for `AppShell` actions. */
|
|
166
|
+
export function PreferenceControls() {
|
|
167
|
+
const { locale, setLocale, theme, setTheme } = usePreferences();
|
|
168
|
+
const t = useUiMessages();
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<DropdownMenu>
|
|
172
|
+
<DropdownMenuTrigger
|
|
173
|
+
render={<Button variant="ghost" size="sm" aria-label={t.language} />}
|
|
174
|
+
>
|
|
175
|
+
<LanguagesIcon />
|
|
176
|
+
{locale === "en" ? "EN" : "PT"}
|
|
177
|
+
</DropdownMenuTrigger>
|
|
178
|
+
<DropdownMenuContent align="end">
|
|
179
|
+
<DropdownMenuRadioGroup
|
|
180
|
+
value={locale}
|
|
181
|
+
onValueChange={(value) => setLocale(value as Locale)}
|
|
182
|
+
>
|
|
183
|
+
{locales.map((value) => (
|
|
184
|
+
<DropdownMenuRadioItem key={value} value={value}>
|
|
185
|
+
{localeNames[value]}
|
|
186
|
+
</DropdownMenuRadioItem>
|
|
187
|
+
))}
|
|
188
|
+
</DropdownMenuRadioGroup>
|
|
189
|
+
</DropdownMenuContent>
|
|
190
|
+
</DropdownMenu>
|
|
191
|
+
<Button
|
|
192
|
+
variant="ghost"
|
|
193
|
+
size="icon-sm"
|
|
194
|
+
aria-label={t.toggleTheme}
|
|
195
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
196
|
+
>
|
|
197
|
+
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
198
|
+
</Button>
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|