feat(i18n): add RTL layout support for Arabic and other RTL locales

- Export `isLocaleRTL` from `@sofa/i18n` to detect right-to-left locales
- Native: call `I18nManager.allowRTL` / `forceRTL` on locale init and on locale change; reload the app immediately if the current layout direction doesn't match the persisted locale, and prompt the user to restart when switching between LTR and RTL locales
- Native: flip the `SettingsRow` chevron to `IconChevronLeft` when `I18nManager.isRTL` is true
- Native: add Arabic Intl polyfill locale data (PluralRules, NumberFormat, DateTimeFormat, RelativeTimeFormat)
- Web: enable `"rtl": true` in `components.json` and regenerate all shadcn UI components to use CSS logical properties (`ms-*`, `me-*`, `ps-*`, `pe-*`, `text-start`, `text-end`) instead of physical `ml-*`/`mr-*`/`text-left` equivalents
- Web: apply the same logical-property conversions to non-generated components (nav-bar, settings sections, title cards, hero banner, continue-watching card)
- Web: set `document.dir` based on the active locale's RTL flag in the i18n initialiser and root route
- Add `../../packages/i18n/src/po.d.ts` to native `tsconfig.json` includes so TypeScript accepts `.po` imports
This commit is contained in:
2026-03-20 19:25:04 -04:00
parent b540a9111a
commit 5aaf88591b
86 changed files with 18380 additions and 1398 deletions
+1 -1
View File
@@ -64,7 +64,7 @@
"expo-system-ui": "55.0.10",
"expo-tracking-transparency": "55.0.9",
"expo-web-browser": "55.0.10",
"posthog-react-native": "4.37.4",
"posthog-react-native": "4.37.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
+31 -27
View File
@@ -84,34 +84,38 @@ export default function LoginScreen() {
return (
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
{showOidc && (
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="mb-4">
<Button
onPress={() => {
authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/(tabs)/(home)",
});
}}
variant="secondary"
className="w-full"
>
<ButtonLabel>
<Trans>Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}</Trans>
</ButtonLabel>
</Button>
{showOidc &&
(() => {
const providerName = authConfig.data?.oidcProviderName ?? "SSO";
return (
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="mb-4">
<Button
onPress={() => {
authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/(tabs)/(home)",
});
}}
variant="secondary"
className="w-full"
>
<ButtonLabel>
<Trans>Sign in with {providerName}</Trans>
</ButtonLabel>
</Button>
{showPasswordLogin && (
<View className="my-4 flex-row items-center">
<View className="bg-border h-px flex-1" />
<Text className="text-muted-foreground px-3 text-xs">
<Trans>OR</Trans>
</Text>
<View className="bg-border h-px flex-1" />
</View>
)}
</Animated.View>
)}
{showPasswordLogin && (
<View className="my-4 flex-row items-center">
<View className="bg-border h-px flex-1" />
<Text className="text-muted-foreground px-3 text-xs">
<Trans>OR</Trans>
</Text>
<View className="bg-border h-px flex-1" />
</View>
)}
</Animated.View>
);
})()}
{showPasswordLogin && (
<View className="gap-3">
+8 -2
View File
@@ -111,7 +111,10 @@ export default function DashboardScreen() {
<View className="gap-3">
<View className="flex-row gap-3">
<StatsCard
label={t`Movies ${periodLabels[moviePeriod]}`}
label={(() => {
const period = periodLabels[moviePeriod];
return t`Movies ${period}`;
})()}
value={movieCount}
icon={IconMovie}
color="text-primary"
@@ -122,7 +125,10 @@ export default function DashboardScreen() {
onPeriodChange={setMoviePeriod}
/>
<StatsCard
label={t`Episodes ${periodLabels[episodePeriod]}`}
label={(() => {
const period = periodLabels[episodePeriod];
return t`Episodes ${period}`;
})()}
value={episodeCount}
icon={IconDeviceTvOld}
color="text-status-watching"
+31 -11
View File
@@ -21,6 +21,7 @@ import {
IconWorld,
} from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { reloadAppAsync } from "expo";
import * as Application from "expo-application";
import * as ImagePicker from "expo-image-picker";
import { useRouter } from "expo-router";
@@ -54,7 +55,7 @@ import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { authClient, getServerUrl } from "@/lib/server";
import { toast } from "@/lib/toast";
import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { activateLocale, isLocaleRTL, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
const settingsContentContainerStyle = {
@@ -421,7 +422,10 @@ export default function SettingsScreen() {
label={t`Database`}
value={
systemHealth.data?.database
? t`${plural(systemHealth.data.database.titleCount, { one: "# title", other: "# titles" })}`
? plural(systemHealth.data.database.titleCount, {
one: "# title",
other: "# titles",
})
: "—"
}
icon={IconDatabase}
@@ -435,7 +439,10 @@ export default function SettingsScreen() {
label={t`Image Cache`}
value={
systemHealth.data?.imageCache
? t`${plural(systemHealth.data.imageCache.imageCount, { one: "# image", other: "# images" })}`
? plural(systemHealth.data.imageCache.imageCount, {
one: "# image",
other: "# images",
})
: "—"
}
icon={IconPhoto}
@@ -472,13 +479,16 @@ export default function SettingsScreen() {
/>
}
/>
{updateCheck.data?.updateCheck?.updateAvailable && (
<View className="py-3.5">
<Text className="text-status-completed font-sans text-sm font-medium">
<Trans>Update available: {updateCheck.data.updateCheck.latestVersion}</Trans>
</Text>
</View>
)}
{(() => {
const latestVersion = updateCheck.data?.updateCheck?.latestVersion;
return updateCheck.data?.updateCheck?.updateAvailable ? (
<View className="py-3.5">
<Text className="text-status-completed font-sans text-sm font-medium">
<Trans>Update available: {latestVersion}</Trans>
</Text>
</View>
) : null;
})()}
</SettingsSection>
</Animated.View>
)}
@@ -512,8 +522,18 @@ export default function SettingsScreen() {
}))}
onSelect={(locale) => {
setLanguageModalOpen(false);
const previousLocale = i18n.locale;
activateLocale(locale as SupportedLocale).then(
() => setPersistedLocale(locale as SupportedLocale),
() => {
setPersistedLocale(locale as SupportedLocale);
if (isLocaleRTL(locale) !== isLocaleRTL(previousLocale)) {
Alert.alert(
t`Restart Required`,
t`Sofa needs to restart to apply the new layout direction.`,
[{ text: t`Restart`, onPress: () => reloadAppAsync() }],
);
}
},
() => {},
);
}}
@@ -1,7 +1,7 @@
import type { Icon } from "@tabler/icons-react-native";
import { IconChevronRight } from "@tabler/icons-react-native";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react-native";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { I18nManager, Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { ScaledIcon } from "@/components/ui/scaled-icon";
@@ -53,7 +53,12 @@ export function SettingsRow({
</Text>
) : null}
{!right && onPress && (
<ScaledIcon icon={IconChevronRight} size={16} color={mutedFgColor} accessible={false} />
<ScaledIcon
icon={I18nManager.isRTL ? IconChevronLeft : IconChevronRight}
size={16}
color={mutedFgColor}
accessible={false}
/>
)}
</>
);
@@ -99,6 +99,9 @@ export function SeasonAccordion({
[watchedEpisodeIds, unwatchEpisode, watchEpisode],
);
const seasonNumber = season.seasonNumber;
const episodeCount = episodes.length;
return (
<View
className="bg-card mb-2 overflow-hidden rounded-xl border"
@@ -110,16 +113,16 @@ export function SeasonAccordion({
<Pressable
onPress={toggleExpanded}
accessibilityRole="button"
accessibilityLabel={`${season.name ?? t`Season ${season.seasonNumber}`}, ${t`${watchedCount} of ${episodes.length} episodes watched`}`}
accessibilityLabel={`${season.name ?? t`Season ${seasonNumber}`}, ${t`${watchedCount} of ${episodeCount} episodes watched`}`}
accessibilityState={{ expanded }}
className="flex-row items-center justify-between p-4"
>
<View className="flex-1">
<Text className="text-foreground font-sans text-base font-medium">
{season.name ?? t`Season ${season.seasonNumber}`}
{season.name ?? t`Season ${seasonNumber}`}
</Text>
<Text className="text-muted-foreground mt-0.5 text-xs">
{t`${watchedCount}/${episodes.length} ${plural(episodes.length, { one: "episode", other: "episodes" })}`}
{t`${watchedCount}/${plural(episodeCount, { one: "# episode", other: "# episodes" })}`}
</Text>
</View>
+3 -2
View File
@@ -81,9 +81,10 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const updateRating = useMutation(
orpc.titles.updateRating.mutationOptions({
onSuccess: (_data, input) => {
const stars = input.stars;
const defaultMsg =
input.stars > 0
? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}`
stars > 0
? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}`
: t`Rating removed`;
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
// Rating only invalidates title queries, not dashboard
+14 -1
View File
@@ -1,6 +1,8 @@
import { reloadAppAsync } from "expo";
import * as Localization from "expo-localization";
import { I18nManager } from "react-native";
import { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
import { activateLocale, isLocaleRTL, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
import { globalStorage } from "./mmkv";
@@ -23,10 +25,21 @@ export function getPersistedLocale(): SupportedLocale {
export function setPersistedLocale(locale: SupportedLocale): void {
globalStorage.set(LOCALE_STORAGE_KEY, locale);
const rtl = isLocaleRTL(locale);
I18nManager.allowRTL(rtl);
I18nManager.forceRTL(rtl);
}
export function initLocale(): Promise<void> {
const locale = getPersistedLocale();
const rtl = isLocaleRTL(locale);
I18nManager.allowRTL(rtl);
I18nManager.forceRTL(rtl);
// forceRTL only takes effect on the next launch — if the current layout
// doesn't match (e.g. fresh install on an RTL device), reload immediately.
if (I18nManager.isRTL !== rtl) {
reloadAppAsync();
}
if (locale !== "en") {
return activateLocale(locale);
}
+24
View File
@@ -20,6 +20,12 @@ import "@formatjs/intl-pluralrules/locale-data/de.js";
import "@formatjs/intl-pluralrules/locale-data/es.js";
import "@formatjs/intl-pluralrules/locale-data/it.js";
import "@formatjs/intl-pluralrules/locale-data/pt.js";
import "@formatjs/intl-pluralrules/locale-data/ar.js";
import "@formatjs/intl-pluralrules/locale-data/nl.js";
import "@formatjs/intl-pluralrules/locale-data/he.js";
import "@formatjs/intl-pluralrules/locale-data/zh.js";
import "@formatjs/intl-pluralrules/locale-data/ja.js";
import "@formatjs/intl-pluralrules/locale-data/ko.js";
// 4. NumberFormat (depends on PluralRules, Locale)
import "@formatjs/intl-numberformat/polyfill.js";
import "@formatjs/intl-numberformat/locale-data/en.js";
@@ -28,6 +34,12 @@ import "@formatjs/intl-numberformat/locale-data/de.js";
import "@formatjs/intl-numberformat/locale-data/es.js";
import "@formatjs/intl-numberformat/locale-data/it.js";
import "@formatjs/intl-numberformat/locale-data/pt.js";
import "@formatjs/intl-numberformat/locale-data/ar.js";
import "@formatjs/intl-numberformat/locale-data/nl.js";
import "@formatjs/intl-numberformat/locale-data/he.js";
import "@formatjs/intl-numberformat/locale-data/zh.js";
import "@formatjs/intl-numberformat/locale-data/ja.js";
import "@formatjs/intl-numberformat/locale-data/ko.js";
// 5. DateTimeFormat (depends on Locale)
import "@formatjs/intl-datetimeformat/polyfill.js";
import "@formatjs/intl-datetimeformat/locale-data/en.js";
@@ -36,6 +48,12 @@ import "@formatjs/intl-datetimeformat/locale-data/de.js";
import "@formatjs/intl-datetimeformat/locale-data/es.js";
import "@formatjs/intl-datetimeformat/locale-data/it.js";
import "@formatjs/intl-datetimeformat/locale-data/pt.js";
import "@formatjs/intl-datetimeformat/locale-data/ar.js";
import "@formatjs/intl-datetimeformat/locale-data/nl.js";
import "@formatjs/intl-datetimeformat/locale-data/he.js";
import "@formatjs/intl-datetimeformat/locale-data/zh.js";
import "@formatjs/intl-datetimeformat/locale-data/ja.js";
import "@formatjs/intl-datetimeformat/locale-data/ko.js";
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
import "@formatjs/intl-relativetimeformat/polyfill.js";
import "@formatjs/intl-relativetimeformat/locale-data/en.js";
@@ -44,3 +62,9 @@ import "@formatjs/intl-relativetimeformat/locale-data/de.js";
import "@formatjs/intl-relativetimeformat/locale-data/es.js";
import "@formatjs/intl-relativetimeformat/locale-data/it.js";
import "@formatjs/intl-relativetimeformat/locale-data/pt.js";
import "@formatjs/intl-relativetimeformat/locale-data/ar.js";
import "@formatjs/intl-relativetimeformat/locale-data/nl.js";
import "@formatjs/intl-relativetimeformat/locale-data/he.js";
import "@formatjs/intl-relativetimeformat/locale-data/zh.js";
import "@formatjs/intl-relativetimeformat/locale-data/ja.js";
import "@formatjs/intl-relativetimeformat/locale-data/ko.js";
+8 -1
View File
@@ -7,5 +7,12 @@
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"uniwind-types.d.ts",
"../../packages/i18n/src/po.d.ts"
]
}
+1 -1
View File
@@ -11,7 +11,7 @@
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"rtl": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
+4 -4
View File
@@ -28,7 +28,7 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.2",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.167.5",
"@tanstack/react-router": "1.168.1",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -37,7 +37,7 @@
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"shadcn": "4.0.8",
"shadcn": "4.1.0",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"thumbhash": "catalog:",
@@ -51,8 +51,8 @@
"@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.9",
"@tanstack/router-plugin": "1.166.14",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/router-plugin": "1.167.1",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
+2 -1
View File
@@ -48,6 +48,7 @@ export function AuthForm({
const isRegister = mode === "register";
const showOidc = authConfig?.oidcEnabled ?? false;
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
const oidcProviderName = authConfig?.oidcProviderName || "SSO";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -139,7 +140,7 @@ export function AuthForm({
{oidcLoading ? (
<Trans>Redirecting</Trans>
) : (
<Trans>Sign in with {authConfig?.oidcProviderName || "SSO"}</Trans>
<Trans>Sign in with {oidcProviderName}</Trans>
)}
</Button>
</motion.div>
@@ -27,6 +27,8 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps
const { t } = useLingui();
const stillUrl = item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress = item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0;
const watchedEpisodes = item.watchedEpisodes;
const totalEpisodes = item.totalEpisodes;
return (
<Link
@@ -63,7 +65,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps
{t`Up next`}
</p>
<p className="mt-0.5 truncate text-sm font-medium text-white">
<span className="mr-0.5 font-mono text-xs text-white/60 [word-spacing:-0.25em]">
<span className="me-0.5 font-mono text-xs text-white/60 [word-spacing:-0.25em]">
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
</span>{" "}
{item.nextEpisode.name}
@@ -75,7 +77,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title.title}</p>
<p className="text-muted-foreground text-xs">
{t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`}
{t`${watchedEpisodes}/${plural(totalEpisodes, { one: "# episode", other: "# episodes" })}`}
</p>
</div>
<div className="bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
@@ -70,7 +70,7 @@ export function HeroBanner({
<Trans>Trending today</Trans>
</span>
</div>
<Link to="/titles/$id" params={{ id }} className="group/title text-left">
<Link to="/titles/$id" params={{ id }} className="group/title text-start">
<h2 className="font-display group-hover/title:text-primary text-3xl tracking-tight text-balance transition-colors sm:text-4xl">
{title}
</h2>
+3 -3
View File
@@ -188,7 +188,7 @@ export function NavBar({
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>{t`Search…`}</span>
<Kbd className="ml-2.5"> K</Kbd>
<Kbd className="ms-2.5"> K</Kbd>
</button>
<Separator
orientation="vertical"
@@ -219,7 +219,7 @@ export function NavBar({
<p className="text-foreground truncate text-sm leading-tight font-medium">
{userName}
{userRole === "admin" && (
<Badge className="bg-primary/10 text-primary mb-0.5 ml-1.5 rounded-md border-0 align-middle">
<Badge className="bg-primary/10 text-primary ms-1.5 mb-0.5 rounded-md border-0 align-middle">
<Trans>Admin</Trans>
</Badge>
)}
@@ -291,7 +291,7 @@ export function MobileTabBar() {
return (
<nav
aria-label="Primary"
className="border-border/50 bg-background/90 fixed right-0 bottom-0 left-0 z-50 border-t pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)] backdrop-blur-xl sm:hidden"
className="border-border/50 bg-background/90 fixed right-0 bottom-0 left-0 z-50 border-t ps-[env(safe-area-inset-left)] pe-[env(safe-area-inset-right)] backdrop-blur-xl sm:hidden"
>
<div ref={containerRef} className="relative flex h-14 items-stretch">
{mobileTabs.map((tab, i) => {
@@ -294,7 +294,7 @@ export function AccountSection({
transition={{ duration: 0.1 }}
type="button"
onClick={() => setIsEditingName(true)}
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-left transition-colors"
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors"
>
{displayName}
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
@@ -305,7 +305,7 @@ export function AccountSection({
<CardDescription>
{user.email}
{user.role === "admin" && (
<Badge className="bg-primary/10 text-primary ml-1.5 rounded-md border-0 align-middle">
<Badge className="bg-primary/10 text-primary ms-1.5 rounded-md border-0 align-middle">
<Trans>Admin</Trans>
</Badge>
)}
@@ -218,31 +218,28 @@ export function BackupScheduleSection() {
<CardDescription>
{enabled ? (
<span className="inline-flex flex-wrap items-baseline" suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}.{" "}
<Trans>
Keeping{" "}
<Select
value={String(maxRetention)}
onValueChange={(v) => v && changeMaxRetention(Number(v))}
modal={false}
>
<SelectTrigger className="decoration-muted-foreground/50 hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground mr-0.5 ml-1.5 !h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-dotted underline-offset-4 shadow-none hover:bg-transparent focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
<SelectValue>
{(value: string | null) =>
value === "0" ? t`unlimited` : value ? t`last ${value}` : null
}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? t`unlimited` : t`last ${n}`}
</SelectItem>
))}
</SelectContent>
</Select>{" "}
backups.
</Trans>
{formatNextBackup(frequency, time, dow)}. <Trans>Keeping</Trans>{" "}
<Select
value={String(maxRetention)}
onValueChange={(v) => v && changeMaxRetention(Number(v))}
modal={false}
>
<SelectTrigger className="decoration-muted-foreground/50 hover:text-foreground hover:decoration-foreground/50 focus-visible:decoration-foreground ms-1.5 me-0.5 !h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent p-0 underline decoration-dotted underline-offset-4 shadow-none hover:bg-transparent focus-visible:decoration-solid focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent">
<SelectValue>
{(value: string | null) =>
value === "0" ? t`unlimited` : value ? t`last ${value}` : null
}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? t`unlimited` : t`last ${n}`}
</SelectItem>
))}
</SelectContent>
</Select>{" "}
<Trans>backups.</Trans>
</span>
) : (
<Trans>Automatically back up your database on a schedule</Trans>
@@ -93,9 +93,10 @@ export function BackupSection() {
const creating = createMutation.isPending;
const deleting = deleteMutation.isPending ? (deleteMutation.variables?.filename ?? null) : null;
const backupCount = displayBackups.length;
const backupCountLabel =
displayBackups.length > 0
? t`${displayBackups.length} ${plural(displayBackups.length, { one: "backup", other: "backups" })} stored`
backupCount > 0
? t`${plural(backupCount, { one: "# backup", other: "# backups" })} stored`
: t`No backups yet`;
return (
@@ -126,136 +127,136 @@ export function BackupSection() {
{displayBackups.length > 0 && (
<CardContent className="border-border/30 border-t pt-4">
<div className="space-y-1.5">
{displayBackups.map((backup: BackupInfo) => (
<motion.div
key={backup.filename}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="group hover:bg-muted/40 flex items-center gap-3 rounded-md px-2.5 py-1.5 transition-colors">
<Tooltip>
<TooltipTrigger
render={
<span className="text-muted-foreground flex shrink-0 items-center" />
}
>
{backup.source === "scheduled" ? (
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" ? (
<IconShieldCheck aria-hidden={true} className="size-3.5" />
) : (
<IconPointer aria-hidden={true} className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent>
{backup.source === "scheduled"
? t`Scheduled backup`
: backup.source === "pre-restore"
? t`Pre-restore backup`
: t`Manual backup`}
</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-xs font-medium">
{formatBackupDate(backup.createdAt)}
</span>
<span className="text-muted-foreground text-[11px]">
{formatBytesI18n(backup.sizeBytes)}
</span>
<span
className="text-muted-foreground/50 text-[11px]"
suppressHydrationWarning
>
{formatRelativeTime(backup.createdAt)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
{displayBackups.map((backup: BackupInfo) => {
const backupDate = formatBackupDate(backup.createdAt);
return (
<motion.div
key={backup.filename}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="group hover:bg-muted/40 flex items-center gap-3 rounded-md px-2.5 py-1.5 transition-colors">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
nativeButton={false}
render={
<a
href={`/api/backup/${backup.filename}`}
download
aria-label={t`Download backup`}
>
<IconCloudDownload />
</a>
}
/>
<span className="text-muted-foreground flex shrink-0 items-center" />
}
/>
>
{backup.source === "scheduled" ? (
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" ? (
<IconShieldCheck aria-hidden={true} className="size-3.5" />
) : (
<IconPointer aria-hidden={true} className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent>
<Trans>Download</Trans>
{backup.source === "scheduled"
? t`Scheduled backup`
: backup.source === "pre-restore"
? t`Pre-restore backup`
: t`Manual backup`}
</TooltipContent>
</Tooltip>
<AlertDialog>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-xs font-medium">{backupDate}</span>
<span className="text-muted-foreground text-[11px]">
{formatBytesI18n(backup.sizeBytes)}
</span>
<span
className="text-muted-foreground/50 text-[11px]"
suppressHydrationWarning
>
{formatRelativeTime(backup.createdAt)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Tooltip>
<AlertDialogTrigger
<TooltipTrigger
render={
<TooltipTrigger
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
nativeButton={false}
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Delete backup`}
className="text-muted-foreground hover:text-destructive"
disabled={deleting === backup.filename}
/>
<a
href={`/api/backup/${backup.filename}`}
download
aria-label={t`Download backup`}
>
<IconCloudDownload />
</a>
}
/>
}
>
{deleting === backup.filename ? <Spinner /> : <IconTrash />}
</AlertDialogTrigger>
/>
<TooltipContent>
<Trans>Delete</Trans>
<Trans>Download</Trans>
</TooltipContent>
</Tooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Delete backup?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will permanently delete the backup from{" "}
<strong>{formatBackupDate(backup.createdAt)}</strong>. This cannot
be undone.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
deleteMutation.mutate({
filename: backup.filename,
})
<AlertDialog>
<Tooltip>
<AlertDialogTrigger
render={
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Delete backup`}
className="text-muted-foreground hover:text-destructive"
disabled={deleting === backup.filename}
/>
}
/>
}
>
{deleting === backup.filename ? <Spinner /> : <IconTrash />}
</AlertDialogTrigger>
<TooltipContent>
<Trans>Delete</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipContent>
</Tooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Delete backup?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will permanently delete the backup from{" "}
<strong>{backupDate}</strong>. This cannot be undone.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
deleteMutation.mutate({
filename: backup.filename,
})
}
>
<Trans>Delete</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</motion.div>
))}
</motion.div>
);
})}
</div>
</CardContent>
)}
@@ -277,8 +277,10 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
warnings: event.job.warnings,
});
setStep("done");
if (event.job.importedCount > 0) {
toast.success(t`Imported ${event.job.importedCount} items from ${config.label}`);
const importedCount = event.job.importedCount;
const sourceLabel = config.label;
if (importedCount > 0) {
toast.success(t`Imported ${importedCount} items from ${sourceLabel}`);
}
} else if (event.type === "timeout") {
receivedComplete = true;
@@ -366,8 +368,9 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const sourceLabel = config.label;
throw new Error(
(err as { error?: string }).error ?? t`Failed to start ${config.label} connection`,
(err as { error?: string }).error ?? t`Failed to start ${sourceLabel} connection`,
);
}
const data = (await res.json()) as DeviceCodeInfo;
@@ -546,14 +549,16 @@ function ChooseStep({
onCancel: () => void;
}) {
const { t } = useLingui();
const sourceLabel = config.label;
const acceptFormat = config.accept;
return (
<>
<DialogHeader>
<DialogTitle>
<Trans>Import from {config.label}</Trans>
<Trans>Import from {sourceLabel}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Choose how to import your {config.label} data.</Trans>
<Trans>Choose how to import your {sourceLabel} data.</Trans>
</DialogDescription>
</DialogHeader>
@@ -566,7 +571,7 @@ function ChooseStep({
<button
type="button"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-start transition-colors"
onClick={onConnect}
>
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
@@ -574,17 +579,17 @@ function ChooseStep({
</div>
<div>
<p className="text-sm font-medium">
<Trans>Connect with {config.label}</Trans>
<Trans>Connect with {sourceLabel}</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Authorize Sofa to read your {config.label} library. No password shared.</Trans>
<Trans>Authorize Sofa to read your {sourceLabel} library. No password shared.</Trans>
</p>
</div>
</button>
<button
type="button"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors"
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-start transition-colors"
onClick={() => {
onCancel();
// Small delay so the dialog closes before file picker opens
@@ -600,7 +605,7 @@ function ChooseStep({
<Trans>Upload export file</Trans>
</p>
<p className="text-muted-foreground text-xs">
{t`Upload a ${config.accept} export from your ${config.label} account settings.`}
{t`Upload a ${acceptFormat} export from your ${sourceLabel} account settings.`}
</p>
</div>
</button>
@@ -725,6 +730,12 @@ function PreviewStep({
(options.importWatchlist ? stats.watchlist : 0) +
(options.importRatings ? stats.ratings : 0);
const movieCount = stats.movies;
const episodeCount = stats.episodes;
const watchlistCount = stats.watchlist;
const ratingCount = stats.ratings;
const unresolvedCount = preview.diagnostics?.unresolved ?? 0;
return (
<>
<DialogHeader>
@@ -738,18 +749,18 @@ function PreviewStep({
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-3">
<StatBadge label={t`Movies`} count={stats.movies} />
<StatBadge label={t`Episodes`} count={stats.episodes} />
<StatBadge label={t`Watchlist`} count={stats.watchlist} />
<StatBadge label={t`Ratings`} count={stats.ratings} />
<StatBadge label={t`Movies`} count={movieCount} />
<StatBadge label={t`Episodes`} count={episodeCount} />
<StatBadge label={t`Watchlist`} count={watchlistCount} />
<StatBadge label={t`Ratings`} count={ratingCount} />
</div>
{preview.diagnostics && preview.diagnostics.unresolved > 0 && (
{preview.diagnostics && unresolvedCount > 0 && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
<Trans>
{preview.diagnostics.unresolved} items have no external IDs and will be resolved by
title search, which may be less accurate.
{unresolvedCount} items have no external IDs and will be resolved by title search,
which may be less accurate.
</Trans>
</p>
</div>
@@ -761,19 +772,19 @@ function PreviewStep({
</p>
<OptionCheckbox
label={t`Watch history`}
description={t`${stats.movies} movies, ${stats.episodes} episodes`}
description={t`${movieCount} movies, ${episodeCount} episodes`}
checked={options.importWatches}
onChange={(v) => setOptions({ ...options, importWatches: v })}
/>
<OptionCheckbox
label={t`Watchlist`}
description={t`${stats.watchlist} items`}
description={t`${watchlistCount} items`}
checked={options.importWatchlist}
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
/>
<OptionCheckbox
label={t`Ratings`}
description={t`${stats.ratings} ratings`}
description={t`${ratingCount} ratings`}
checked={options.importRatings}
onChange={(v) => setOptions({ ...options, importRatings: v })}
/>
@@ -863,6 +874,9 @@ function DoneStep({
onClose: () => void;
}) {
const { t } = useLingui();
const errorCount = result.errors.length;
const warningCount = result.warnings.length;
const remainingErrors = errorCount - 50;
return (
<>
<DialogHeader>
@@ -881,28 +895,28 @@ function DoneStep({
<StatBadge label={t`Failed`} count={result.failed} />
</div>
{result.errors.length > 0 && (
{errorCount > 0 && (
<div className="bg-destructive/10 max-h-40 overflow-y-auto rounded-lg p-3">
<p className="text-destructive mb-1 text-xs font-medium">
<Trans>Errors ({result.errors.length})</Trans>
<Trans>Errors ({errorCount})</Trans>
</p>
<ul className="text-destructive/80 space-y-0.5 text-xs">
{result.errors.slice(0, 50).map((e, i) => (
<li key={i}>{e}</li>
))}
{result.errors.length > 50 && (
{errorCount > 50 && (
<li>
<Trans>...and {result.errors.length - 50} more</Trans>
<Trans>...and {remainingErrors} more</Trans>
</li>
)}
</ul>
</div>
)}
{result.warnings.length > 0 && (
{warningCount > 0 && (
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 text-xs font-medium text-yellow-600">
<Trans>Warnings ({result.warnings.length})</Trans>
<Trans>Warnings ({warningCount})</Trans>
</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80">
{result.warnings.slice(0, 20).map((w, i) => (
@@ -147,7 +147,7 @@ export function IntegrationCard({
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<Icon className="text-primary size-4" />
</div>
<div className="text-left">
<div className="text-start">
<CardTitle>{config.label}</CardTitle>
<CardDescription>
{connection ? config.connectedStatus(connection.lastEventAt) : t`Not configured`}
@@ -172,7 +172,7 @@ export function IntegrationCard({
size="lg"
className="w-full"
>
{connecting ? <Trans>Connecting...</Trans> : <Trans>Connect {config.label}</Trans>}
{connecting ? <Trans>Connecting...</Trans> : <Trans>Connect {label}</Trans>}
</Button>
) : (
<AnimatePresence>
@@ -251,10 +251,10 @@ export function IntegrationCard({
<div className="border-border/50 bg-muted/30 text-muted-foreground mt-2 rounded-lg border p-3 text-xs leading-relaxed">
<ol className="list-inside list-decimal space-y-1.5">{config.setupSteps}</ol>
{config.docsUrl && (
<p className="mt-2 -ml-0.5">
<p className="-ms-0.5 mt-2">
<IconBook2
aria-hidden={true}
className="mr-1 inline-block size-3 translate-y-[-1px]"
className="me-1 inline-block size-3 translate-y-[-1px]"
/>
<Trans>Need more help?</Trans>{" "}
<a
@@ -285,14 +285,18 @@ export function IntegrationCard({
/** Status line for webhook integrations (shows last event time). */
export function webhookStatus(lastEventAt: string | null): string {
return lastEventAt
? i18n._(msg`Last event ${formatRelativeTime(lastEventAt)}`)
: i18n._(msg`Ready — nothing received yet`);
if (lastEventAt) {
const relativeTime = formatRelativeTime(lastEventAt);
return i18n._(msg`Last event ${relativeTime}`);
}
return i18n._(msg`Ready — nothing received yet`);
}
/** Status line for list integrations (shows last event time). */
export function listStatus(lastEventAt: string | null): string {
return lastEventAt
? i18n._(msg`Last polled ${formatRelativeTime(lastEventAt)}`)
: i18n._(msg`Ready — not polled yet`);
if (lastEventAt) {
const relativeTime = formatRelativeTime(lastEventAt);
return i18n._(msg`Last polled ${relativeTime}`);
}
return i18n._(msg`Ready — not polled yet`);
}
@@ -29,7 +29,7 @@ export function LanguageSection() {
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<IconLanguage className="text-primary size-4" />
</div>
<div className="text-left">
<div className="text-start">
<CardTitle>
<Trans>Language</Trans>
</CardTitle>
@@ -46,28 +46,31 @@ export function LanguageSection() {
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent className="border-border/30 border-t pt-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{LOCALE_INFO.map((info) => (
<button
key={info.code}
type="button"
onClick={() => handleLocaleChange(info.code)}
className={`relative flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
currentLocale === info.code
? "border-primary bg-primary/5 text-foreground"
: "border-border hover:border-primary/40 hover:bg-primary/5"
}`}
aria-label={t`Switch to ${info.name}`}
aria-pressed={currentLocale === info.code}
>
<div className="min-w-0">
<div className="truncate font-medium">{info.nativeName}</div>
<div className="text-muted-foreground truncate text-xs">{info.name}</div>
</div>
{currentLocale === info.code && (
<IconCheck className="text-primary ml-auto size-4 shrink-0" />
)}
</button>
))}
{LOCALE_INFO.map((info) => {
const name = info.name;
return (
<button
key={info.code}
type="button"
onClick={() => handleLocaleChange(info.code)}
className={`relative flex items-center gap-2 rounded-lg border px-3 py-2 text-start text-sm transition-colors ${
currentLocale === info.code
? "border-primary bg-primary/5 text-foreground"
: "border-border hover:border-primary/40 hover:bg-primary/5"
}`}
aria-label={t`Switch to ${name}`}
aria-pressed={currentLocale === info.code}
>
<div className="min-w-0">
<div className="truncate font-medium">{info.nativeName}</div>
<div className="text-muted-foreground truncate text-xs">{info.name}</div>
</div>
{currentLocale === info.code && (
<IconCheck className="text-primary ml-auto size-4 shrink-0" />
)}
</button>
);
})}
</div>
</CardContent>
</CollapsibleContent>
@@ -80,7 +80,7 @@ export function SkeletonCards() {
return (
<div className="space-y-3">
{["status", "jobs", "storage"].map((s) => (
<Card key={s} className="border-l-primary/30 border-l-2">
<Card key={s} className="border-s-primary/30 border-s-2">
<CardContent>
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-8 w-8 rounded-lg" />
@@ -181,7 +181,7 @@ function SystemStatusCard({
onRefresh: () => void;
}) {
return (
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
@@ -313,7 +313,8 @@ function BackgroundJobsCard({
const triggerJobMutation = useMutation(
orpc.admin.triggerJob.mutationOptions({
onSuccess: (_, { name }) => {
toast.success(t`${JOB_LABELS[name] ?? name} triggered`);
const jobLabel = JOB_LABELS[name] ?? name;
toast.success(t`${jobLabel} triggered`);
setTimeout(onRefresh, 1500);
},
onError: (err) => {
@@ -334,9 +335,10 @@ function BackgroundJobsCard({
});
const activeJobs = jobs.filter((j) => !j.disabled);
const healthyCount = activeJobs.filter((j) => j.lastStatus === "success").length;
const activeJobCount = activeJobs.length;
return (
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
@@ -349,7 +351,7 @@ function BackgroundJobsCard({
</CardTitle>
<CardDescription>
<Trans>
{healthyCount} of {activeJobs.length} jobs healthy
{healthyCount} of {activeJobCount} jobs healthy
</Trans>
</CardDescription>
</div>
@@ -361,7 +363,7 @@ function BackgroundJobsCard({
<Table>
<TableHeader>
<TableRow className="border-b-border/30 hover:bg-transparent">
<TableHead className="text-muted-foreground h-8 pl-5 text-[10px] font-medium tracking-wider uppercase">
<TableHead className="text-muted-foreground h-8 ps-5 text-[10px] font-medium tracking-wider uppercase">
<Trans>Job</Trans>
</TableHead>
<TableHead className="text-muted-foreground h-8 text-[10px] font-medium tracking-wider uppercase">
@@ -373,7 +375,7 @@ function BackgroundJobsCard({
<TableHead className="text-muted-foreground h-8 text-[10px] font-medium tracking-wider uppercase">
<Trans>Next run</Trans>
</TableHead>
<TableHead className="text-muted-foreground h-8 pr-5 text-right text-[10px] font-medium tracking-wider uppercase">
<TableHead className="text-muted-foreground h-8 pe-5 text-end text-[10px] font-medium tracking-wider uppercase">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
@@ -388,7 +390,7 @@ function BackgroundJobsCard({
return (
<TableRow key={job.jobName} className="border-b-border/20 hover:bg-muted/30">
{/* Job name + status */}
<TableCell className="pl-5">
<TableCell className="ps-5">
<div className="flex items-center gap-2">
{job.disabled ? (
<StatusDot status="inactive" label={t`Disabled`} />
@@ -472,7 +474,7 @@ function BackgroundJobsCard({
</TableCell>
{/* Trigger button */}
<TableCell className="pr-5 text-right">
<TableCell className="pe-5 text-end">
<Tooltip>
<TooltipTrigger
render={
@@ -524,8 +526,11 @@ function StorageCard({
onRefresh: () => void;
}) {
const { t } = useLingui();
const cachedImageCount = imageCache.imageCount.toLocaleString();
const backupCount = backups.backupCount;
const lastBackupAt = backups.lastBackupAt;
return (
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
@@ -560,7 +565,7 @@ function StorageCard({
{imageCache.enabled ? (
<>
<p className="text-muted-foreground mt-1 text-xs">
{t`${imageCache.imageCount.toLocaleString()} cached images`}
{t`${cachedImageCount} cached images`}
</p>
<p className="text-muted-foreground/50 mt-0.5 text-[10px] leading-relaxed">
{Object.entries(imageCache.categories)
@@ -582,17 +587,16 @@ function StorageCard({
<span className="text-muted-foreground/70 text-[11px] font-medium tracking-wider uppercase">
<Trans>Backups</Trans>
</span>
{backups.backupCount > 0 && (
{backupCount > 0 && (
<span className="text-muted-foreground/50 font-mono text-[11px]">
{formatBytes(backups.totalSizeBytes)}
</span>
)}
</div>
{backups.backupCount > 0 ? (
{backupCount > 0 ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>
{backups.backupCount} backups · last{" "}
<LiveTimeAgo date={backups.lastBackupAt} fallback={t`unknown`} />
{backupCount} backups · last <LiveTimeAgo date={lastBackupAt} fallback={t`unknown`} />
</Trans>
</p>
) : (
+2 -2
View File
@@ -115,7 +115,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
return (
<Tooltip>
<TooltipTrigger
className="absolute top-2 right-2 z-10 flex size-8 cursor-default items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm"
className="absolute end-2 top-2 z-10 flex size-8 cursor-default items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm"
render={<div />}
>
<StatusIcon className="size-4" />
@@ -129,7 +129,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className="absolute top-2 right-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="absolute end-2 top-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />}
>
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
@@ -6,13 +6,18 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { thumbHashToUrl } from "@/lib/thumbhash";
import type { CastMember } from "@sofa/api/schemas";
function EpisodeCountLabel({ count }: { count: number }) {
const { t } = useLingui();
const suffix = count !== 1 ? "s" : "";
return <p className="text-muted-foreground/70 text-[10px]">{t`${count} ep${suffix}`}</p>;
}
interface CastCarouselProps {
actors: CastMember[];
titleType: "movie" | "tv";
}
export function CastCarousel({ actors, titleType }: CastCarouselProps) {
const { t } = useLingui();
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
@@ -74,9 +79,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</p>
)}
{titleType === "tv" && member.episodeCount && (
<p className="text-muted-foreground/70 text-[10px]">
{t`${member.episodeCount} ep${member.episodeCount !== 1 ? "s" : ""}`}
</p>
<EpisodeCountLabel count={member.episodeCount} />
)}
</div>
</Link>
@@ -161,7 +161,7 @@ export function TitleSeasons({
setOpenSeason(isOpen ? null : season.seasonNumber);
}
}}
className="group/season hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between p-4 text-left transition-colors"
className="group/season hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between p-4 text-start transition-colors"
>
<div className="flex items-center gap-3">
<span className="font-medium">
@@ -253,7 +253,7 @@ export function TitleSeasons({
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
<span className="absolute bottom-2 left-3 font-mono text-[10px] text-white/70">
<span className="absolute start-3 bottom-2 font-mono text-[10px] text-white/70">
E{String(ep.episodeNumber).padStart(2, "0")}
</span>
</div>
@@ -65,8 +65,9 @@ export function useTitleActions() {
try {
await batchWatchMutation.mutateAsync({ episodeIds });
await queryClient.invalidateQueries({ queryKey: userInfoKey });
const count = episodeIds.length;
toast.success(
t`Caught up — marked ${episodeIds.length} ${plural(episodeIds.length, { one: "episode", other: "episodes" })} as watched`,
t`Caught up — marked ${plural(count, { one: "# episode", other: "# episodes" })} as watched`,
);
} catch {
setUserInfo((old) => ({
@@ -112,7 +113,7 @@ export function useTitleActions() {
});
toast.success(
ratingStars > 0
? t`Rated ${ratingStars} ${plural(ratingStars, { one: "star", other: "stars" })}`
? t`Rated ${plural(ratingStars, { one: "# star", other: "# stars" })}`
: t`Rating removed`,
);
} catch {
@@ -195,7 +196,7 @@ export function useTitleActions() {
if (previousUnwatched.length > 0) {
const count = previousUnwatched.length;
toast.success(t`Watched S${seasonNum} E${epNum}`, {
description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
description: t`${plural(count, { one: "# earlier episode", other: "# earlier episodes" })} unwatched`,
action: {
label: t`Catch up`,
onClick: () => catchUp(previousUnwatched),
@@ -263,11 +264,12 @@ export function useTitleActions() {
}
}
const seasonLabel = season.name ?? t`Season ${season.seasonNumber}`;
const seasonNumber = season.seasonNumber;
const seasonLabel = season.name ?? t`Season ${seasonNumber}`;
if (previousUnwatched.length > 0) {
const count = previousUnwatched.length;
toast.success(t`Watched all of ${seasonLabel}`, {
description: t`${count} earlier ${plural(count, { one: "episode", other: "episodes" })} unwatched`,
description: t`${plural(count, { one: "# earlier episode", other: "# earlier episodes" })} unwatched`,
action: {
label: t`Catch up`,
onClick: () => catchUp(previousUnwatched),
@@ -302,7 +304,9 @@ export function useTitleActions() {
try {
await unwatchSeasonMutation.mutateAsync({ id: season.id });
toast.success(t`Unwatched all of ${season.name ?? t`Season ${season.seasonNumber}`}`);
const seasonNumber = season.seasonNumber;
const seasonLabel = season.name ?? t`Season ${seasonNumber}`;
toast.success(t`Unwatched all of ${seasonLabel}`);
} catch {
setUserInfo((old) => ({
...old,
+1 -1
View File
@@ -29,7 +29,7 @@ function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4",
"group/accordion-trigger **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-start text-xs/relaxed font-medium transition-all outline-none hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ms-auto **:data-[slot=accordion-trigger-icon]:size-4",
className,
)}
{...props}
+2 -2
View File
@@ -43,7 +43,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed start-1/2 top-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm rtl:translate-x-1/2",
className,
)}
{...props}
@@ -57,7 +57,7 @@ function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">)
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
"grid grid-rows-[auto_1fr] place-items-center gap-1 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
+2 -6
View File
@@ -4,7 +4,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-start text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pe-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
@@ -62,11 +62,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-1.5 right-2", className)}
{...props}
/>
<div data-slot="alert-action" className={cn("absolute end-2 top-1.5", className)} {...props} />
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"bg-primary text-primary-foreground ring-background absolute end-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
"group/badge focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:ring-[3px] has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
{
variants: {
variant: {
+2 -2
View File
@@ -6,12 +6,12 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
"*:data-slot:rounded-e-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md! [&>[data-slot]~[data-slot]]:rounded-s-none [&>[data-slot]~[data-slot]]:border-s-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
+4 -4
View File
@@ -21,10 +21,10 @@ const buttonVariants = cva(
},
size: {
default:
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-2.5",
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
+4 -4
View File
@@ -102,7 +102,7 @@ function ComboboxContent({
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in *:data-[slot=input-group]:bg-input/20 dark:bg-popover relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in *:data-[slot=input-group]:bg-input/20 dark:bg-popover relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none",
className,
)}
{...props}
@@ -138,7 +138,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
<span className="pointer-events-none absolute end-2 flex items-center justify-center" />
}
>
<IconCheck className="pointer-events-none" />
@@ -218,7 +218,7 @@ function ComboboxChip({
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1.5 text-xs/relaxed font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
"bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1.5 text-xs/relaxed font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0",
className,
)}
{...props}
@@ -227,7 +227,7 @@ function ComboboxChip({
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
className="-ms-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<IconX className="pointer-events-none" />
+2 -2
View File
@@ -148,7 +148,7 @@ function CommandItem({
{...props}
>
{children}
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
<IconCheck className="ms-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
@@ -158,7 +158,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-[0.625rem] tracking-widest",
"text-muted-foreground group-data-selected/command-item:text-foreground ms-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+12 -12
View File
@@ -26,7 +26,7 @@ function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
side = "inline-end",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
@@ -43,7 +43,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none",
className,
)}
{...props}
@@ -68,7 +68,7 @@ function ContextMenuLabel({
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:ps-7.5", className)}
{...props}
/>
);
@@ -89,7 +89,7 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"group/context-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -114,13 +114,13 @@ function ContextMenuSubTrigger({
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
<IconChevronRight className="ms-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
@@ -130,7 +130,7 @@ function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof Context
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
side="inline-end"
{...props}
/>
);
@@ -150,13 +150,13 @@ function ContextMenuCheckboxItem({
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<span className="pointer-events-none absolute end-2 flex items-center justify-center">
<ContextMenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</ContextMenuPrimitive.CheckboxItemIndicator>
@@ -183,12 +183,12 @@ function ContextMenuRadioItem({
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<span className="pointer-events-none absolute end-2 flex items-center justify-center">
<ContextMenuPrimitive.RadioItemIndicator>
<IconCheck />
</ContextMenuPrimitive.RadioItemIndicator>
@@ -213,7 +213,7 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
"text-muted-foreground group-focus/context-menu-item:text-accent-foreground ms-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+2 -2
View File
@@ -48,7 +48,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 outline-none sm:max-w-sm",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 data-closed:animate-out data-open:animate-in fixed start-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 outline-none sm:max-w-sm rtl:translate-x-1/2",
className,
)}
{...props}
@@ -57,7 +57,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
render={<Button variant="ghost" className="absolute end-2 top-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
+11 -11
View File
@@ -37,7 +37,7 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
className,
)}
{...props}
@@ -62,7 +62,7 @@ function DropdownMenuLabel({
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:ps-7.5", className)}
{...props}
/>
);
@@ -83,7 +83,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -108,13 +108,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-popup-open:bg-accent data-open:text-accent-foreground data-popup-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-popup-open:bg-accent data-open:text-accent-foreground data-popup-open:text-accent-foreground flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
<IconChevronRight className="ms-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
@@ -122,7 +122,7 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
side = "inline-end",
sideOffset = 0,
className,
...props
@@ -157,14 +157,14 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
className="pointer-events-none absolute end-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
@@ -193,13 +193,13 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
className="pointer-events-none absolute end-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
@@ -226,7 +226,7 @@ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"spa
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ms-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
+2 -2
View File
@@ -122,7 +122,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-left text-xs/relaxed leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"text-muted-foreground text-start text-xs/relaxed leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
@@ -186,7 +186,7 @@ function FieldError({
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
<ul className="ms-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
);
+1 -1
View File
@@ -31,7 +31,7 @@ function HoverCardContent({
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 w-72 origin-(--transform-origin) rounded-lg p-2.5 text-xs/relaxed shadow-md ring-1 outline-hidden duration-100",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 w-72 origin-(--transform-origin) rounded-lg p-2.5 text-xs/relaxed shadow-md ring-1 outline-hidden duration-100",
className,
)}
{...props}
+3 -3
View File
@@ -12,7 +12,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input bg-input/20 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-7 w-full min-w-0 items-center rounded-md border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-2 has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group border-input bg-input/20 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-7 w-full min-w-0 items-center rounded-md border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-2 has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5",
className,
)}
{...props}
@@ -25,8 +25,8 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem]",
"inline-start": "order-first ps-2 has-[>button]:ms-[-0.275rem] has-[>kbd]:ms-[-0.275rem]",
"inline-end": "order-last pe-2 has-[>button]:me-[-0.275rem] has-[>kbd]:me-[-0.275rem]",
"block-start":
"order-first w-full justify-start px-2 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
+1 -1
View File
@@ -140,7 +140,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="item-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-xs/relaxed font-normal [&>a]:underline [&>a]:underline-offset-4",
"text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-start text-xs/relaxed font-normal [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
+9 -9
View File
@@ -69,7 +69,7 @@ function MenubarContent({
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"data-open:fade-in-0 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100",
"data-open:fade-in-0 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100",
className,
)}
{...props}
@@ -89,7 +89,7 @@ function MenubarItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive! min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed data-disabled:opacity-50 data-inset:pl-7.5 [&_svg:not([class*='size-'])]:size-3.5",
"group/menubar-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive! min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed data-disabled:opacity-50 data-inset:ps-7.5 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -111,13 +111,13 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-7.5 pe-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
@@ -144,12 +144,12 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-7.5 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 ps-7.5 pe-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
@@ -170,7 +170,7 @@ function MenubarLabel({
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:pl-7.5", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs data-inset:ps-7.5", className)}
{...props}
/>
);
@@ -197,7 +197,7 @@ function MenubarShortcut({
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground group-focus/menubar-item:text-accent-foreground ml-auto text-[0.625rem] tracking-widest",
"text-muted-foreground group-focus/menubar-item:text-accent-foreground ms-auto text-[0.625rem] tracking-widest",
className,
)}
{...props}
@@ -221,7 +221,7 @@ function MenubarSubTrigger({
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs data-inset:pl-7.5 [&_svg:not([class*='size-'])]:size-3.5",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs data-inset:ps-7.5 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
@@ -68,7 +68,7 @@ function NavigationMenuTrigger({
>
{children}{" "}
<IconChevronDown
className="relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180"
className="relative top-px ms-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@@ -80,7 +80,7 @@ function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive.
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-open:animate-in h-full w-auto p-1.5 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 data-ending-style:data-activation-direction=left:translate-x-[50%] rtl:data-ending-style:data-activation-direction=left:-translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] rtl:data-ending-style:data-activation-direction=right:-translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] rtl:data-starting-style:data-activation-direction=left:-translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] rtl:data-starting-style:data-activation-direction=right:-translate-x-[50%] data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-open:animate-in h-full w-auto p-1.5 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
@@ -104,7 +104,7 @@ function NavigationMenuPositioner({
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:start-0 data-[side=bottom]:before:end-0 data-[side=bottom]:before:top-[-10px]",
className,
)}
{...props}
@@ -143,7 +143,7 @@ function NavigationMenuIndicator({
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-ss-sm shadow-md" />
</NavigationMenuPrimitive.Icon>
);
}
+2 -2
View File
@@ -62,7 +62,7 @@ function PaginationPrevious({
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-2!", className)}
className={cn("ps-2!", className)}
{...props}
>
<IconChevronLeft data-icon="inline-start" />
@@ -80,7 +80,7 @@ function PaginationNext({
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-2!", className)}
className={cn("pe-2!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
+1 -1
View File
@@ -32,7 +32,7 @@ function PopoverContent({
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-lg p-2.5 text-xs shadow-md ring-1 outline-hidden duration-100",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-lg p-2.5 text-xs shadow-md ring-1 outline-hidden duration-100",
className,
)}
{...props}
+1 -1
View File
@@ -54,7 +54,7 @@ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn("text-muted-foreground ml-auto text-xs/relaxed tabular-nums", className)}
className={cn("text-muted-foreground ms-auto text-xs/relaxed tabular-nums", className)}
data-slot="progress-value"
{...props}
/>
+1 -1
View File
@@ -27,7 +27,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
<span className="bg-primary-foreground absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full rtl:translate-x-1/2" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
+1 -1
View File
@@ -36,7 +36,7 @@ function ScrollArea({
"no-scrollbar focus-visible:ring-ring/50 flex-1 rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1 data-has-overflow-x:overscroll-x-contain",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]",
scrollbarGutter && "data-has-overflow-x:pb-2.5 data-has-overflow-y:pr-2.5",
scrollbarGutter && "data-has-overflow-x:pb-2.5 data-has-overflow-y:pe-2.5",
)}
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content" ref={contentRef}>
+3 -3
View File
@@ -20,7 +20,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
className={cn("flex flex-1 text-start", className)}
{...props}
/>
);
@@ -80,7 +80,7 @@ function SelectContent({
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-popover text-popover-foreground ring-foreground/10 data-closed:animate-out data-open:animate-in relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none",
className,
)}
{...props}
@@ -119,7 +119,7 @@ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Prop
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
<span className="pointer-events-none absolute end-2 flex items-center justify-center" />
}
>
<IconCheck className="pointer-events-none" />
+2 -2
View File
@@ -51,7 +51,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 bg-background data-closed:animate-out data-open:animate-in fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 bg-background data-closed:animate-out data-open:animate-in fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-e data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-s data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
@@ -60,7 +60,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
render={<Button variant="ghost" className="absolute end-4 top-4" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
+13 -13
View File
@@ -225,7 +225,7 @@ function Sidebar({
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-e group-data-[side=right]:border-s",
className,
)}
{...props}
@@ -277,11 +277,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=left]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full rtl:group-data-[collapsible=offcanvas]:-translate-x-0",
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
className,
)}
{...props}
@@ -294,7 +294,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
className,
)}
{...props}
@@ -405,7 +405,7 @@ function SidebarGroupAction({
props: mergeProps<"button">(
{
className: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -453,7 +453,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
"peer/menu-button group/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-start text-xs outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
@@ -542,7 +542,7 @@ function SidebarMenuAction({
props: mergeProps<"button">(
{
className: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 md:opacity-0",
className,
@@ -564,7 +564,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1",
"text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1",
className,
)}
{...props}
@@ -611,7 +611,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s px-2.5 py-0.5 group-data-[collapsible=icon]:hidden rtl:-translate-x-px",
className,
)}
{...props}
@@ -646,7 +646,7 @@ function SidebarMenuSubButton({
props: mergeProps<"a">(
{
className: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs rtl:translate-x-px [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
+1 -1
View File
@@ -21,7 +21,7 @@ function Switch({
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=default]/switch:data-unchecked:translate-x-px group-data-[size=sm]/switch:data-unchecked:translate-x-px"
className="bg-background dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=default]/switch:data-unchecked:translate-x-px group-data-[size=sm]/switch:data-unchecked:translate-x-px rtl:group-data-[size=default]/switch:data-checked:-translate-x-[calc(100%-1px)] rtl:group-data-[size=sm]/switch:data-checked:-translate-x-[calc(100%-1px)] rtl:group-data-[size=default]/switch:data-unchecked:-translate-x-px rtl:group-data-[size=sm]/switch:data-unchecked:-translate-x-px"
/>
</SwitchPrimitive.Root>
);
+2 -2
View File
@@ -56,7 +56,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
"text-foreground h-10 px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className,
)}
{...props}
@@ -68,7 +68,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", className)}
{...props}
/>
);
+1 -1
View File
@@ -52,7 +52,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
"text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50 dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-end-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
+1 -1
View File
@@ -70,7 +70,7 @@ function ToggleGroupItem({
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-s-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-e-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-s-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-s group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
+2 -2
View File
@@ -36,13 +36,13 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:fade-out-0 data-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 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md",
"data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:fade-out-0 data-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 data-[side=inline-start]:slide-in-from-end-2 data-[side=inline-end]:slide-in-from-start-2 bg-foreground text-background data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:-start-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:-end-1 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
+4 -2
View File
@@ -15,8 +15,10 @@ export function UpdateToast({ data }: { data: UpdateCheckResult | null }) {
if (dismissedVersion === data.latestVersion) return;
setDismissedVersion(data.latestVersion);
toast.info(t`Sofa v${data.latestVersion} is available`, {
description: t`You're running v${data.currentVersion}.`,
const latestVersion = data.latestVersion;
const currentVersion = data.currentVersion;
toast.info(t`Sofa v${latestVersion} is available`, {
description: t`You're running v${currentVersion}.`,
duration: 15_000,
action: data.releaseUrl
? {
+3 -1
View File
@@ -1,4 +1,4 @@
import { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
import { activateLocale, getDirection, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n";
const LOCALE_STORAGE_KEY = "sofa:locale";
@@ -17,6 +17,7 @@ export function getPersistedLocale(): SupportedLocale {
export function setPersistedLocale(locale: SupportedLocale): void {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
document.documentElement.lang = locale;
document.documentElement.dir = getDirection(locale);
}
let initialized = false;
@@ -26,6 +27,7 @@ export async function initLocale(): Promise<void> {
initialized = true;
const locale = getPersistedLocale();
document.documentElement.lang = locale;
document.documentElement.dir = getDirection(locale);
if (locale !== "en") {
await activateLocale(locale);
}
+21 -9
View File
@@ -1,5 +1,5 @@
import { I18nProvider } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { IconAlertTriangle } from "@tabler/icons-react";
import { TanStackDevtools } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
@@ -11,11 +11,12 @@ import { MotionConfig } from "motion/react";
import { NavigationProgress } from "@/components/navigation-progress";
import { SofaLogo } from "@/components/sofa-logo";
import { DirectionProvider } from "@/components/ui/direction";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { initLocale } from "@/lib/i18n";
import type { orpc } from "@/lib/orpc/client";
import { i18n } from "@sofa/i18n";
import { getDirection, i18n } from "@sofa/i18n";
import "@/styles/globals.css";
@@ -53,13 +54,7 @@ function RootComponent() {
<HeadContent />
<StoreProvider>
<I18nProvider i18n={i18n}>
<MotionConfig reducedMotion="user">
<NavigationProgress />
<TooltipProvider>
<Outlet />
</TooltipProvider>
<Toaster position="bottom-right" />
</MotionConfig>
<AppShell />
</I18nProvider>
</StoreProvider>
<TanStackDevtools
@@ -78,6 +73,23 @@ function RootComponent() {
);
}
function AppShell() {
const { i18n: linguiI18n } = useLingui();
const direction = getDirection(linguiI18n.locale);
return (
<DirectionProvider direction={direction}>
<MotionConfig reducedMotion="user">
<NavigationProgress />
<TooltipProvider>
<Outlet />
</TooltipProvider>
<Toaster position={direction === "rtl" ? "bottom-left" : "bottom-right"} />
</MotionConfig>
</DirectionProvider>
);
}
function GlobalNotFound() {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6">
+1 -1
View File
@@ -41,7 +41,7 @@ function AppLayout() {
<div className="bg-primary/3 pointer-events-none fixed top-1/4 left-1/2 h-[600px] w-[800px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[200px]" />
<main
id="main-content"
className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"
className="relative mx-auto max-w-6xl py-6 ps-[max(1rem,env(safe-area-inset-left))] pe-[max(1rem,env(safe-area-inset-right))] sm:ps-[max(1.5rem,env(safe-area-inset-left))] sm:pe-[max(1.5rem,env(safe-area-inset-right))]"
>
<Outlet />
</main>
+6 -6
View File
@@ -173,10 +173,10 @@ function SettingsPage() {
</span>
</div>
<div className="space-y-3">
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<RegistrationSection />
</Card>
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<UpdateCheckSection />
</Card>
</div>
@@ -196,13 +196,13 @@ function SettingsPage() {
</span>
</div>
<div className="space-y-3">
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<BackupSection />
</Card>
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<BackupScheduleSection />
</Card>
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<BackupRestoreSection />
</Card>
</div>
@@ -221,7 +221,7 @@ function SettingsPage() {
Admin only
</span>
</div>
<Card className="border-l-primary/30 border-l-2">
<Card className="border-s-primary/30 border-s-2">
<CacheSection />
</Card>
</div>
+1 -1
View File
@@ -14,6 +14,6 @@
},
"types": ["bun"]
},
"include": ["src", "vite.config.ts"],
"include": ["src", "vite.config.ts", "../../packages/i18n/src/po.d.ts"],
"exclude": ["node_modules", "dist"]
}