Move admin tabs to top tabs (#1464)

This commit is contained in:
Zoe Roux 2026-04-18 17:54:29 +02:00 committed by GitHub
commit 6018808e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 190 additions and 77 deletions

View File

@ -204,7 +204,8 @@ const videoRelations = {
.select({ pk: entries.showPk })
.from(entries)
.innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk))
.where(eq(videos.pk, entryVideoJoin.videoPk)),
.where(eq(videos.pk, entryVideoJoin.videoPk))
.limit(1),
),
)
.limit(1)

View File

@ -16,6 +16,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/material-top-tabs": "^7.4.23",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.91.3",
"@tanstack/react-query-devtools": "^5.91.3",
@ -48,6 +49,7 @@
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^4.2.0",
"react-native-nitro-modules": "^0.35.2",
"react-native-pager-view": "^8.0.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
@ -544,6 +546,8 @@
"@react-navigation/elements": ["@react-navigation/elements@2.9.11", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-O5KiwaVCcEVuqZgQ77xiBFSl1sha77rNMTFlLWYnom33ZHPDarV3bM9WNyVnMZxU8ZVTi02X3+ZhO0fSn5QYyg=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.23", "", { "dependencies": { "@react-navigation/elements": "^2.9.14", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-yIU2ne7w+LBjhhiHLwhLIuzjnY44GK6OXqls1112StR4afd5bYYUBL6WopZkBmXD/3sRooimp8iy5Cih+8Mr8w=="],
"@react-navigation/native": ["@react-navigation/native@7.1.34", "", { "dependencies": { "@react-navigation/core": "^7.16.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-zzQ0mKAhLsjTIsaoLfILKZVMObJzE0F+bOi0hl2Glt+1Rd2GtaWJ1Z024c3yLmX+Oc79pqoCQLBXpyxtrZu9NQ=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.14.6", "", { "dependencies": { "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-VRlC5mLanRPHK0E15Cild6U01Z5TDPBlmt5YcXRBc+hQTAMbMT9XcSTobf3sJXNY0zzDD1IpSs3Ynex/GU225g=="],
@ -1338,6 +1342,8 @@
"react-native-nitro-modules": ["react-native-nitro-modules@0.35.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-97cZcCh3ZAuWAfutel2Q3qLfc45XXh7F9Ei5tEjahP0kV3q8hQelwLIulKXmjN+f0JI5Zf/wCsfwwdVWYU2tKA=="],
"react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="],
"react-native-reanimated": ["react-native-reanimated@4.2.1", "", { "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": ">=0.7.0" } }, "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
@ -1348,6 +1354,8 @@
"react-native-svg-transformer": ["react-native-svg-transformer@1.5.3", "", { "dependencies": { "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "path-dirname": "^1.0.2" }, "peerDependencies": { "react-native": ">=0.59.0", "react-native-svg": ">=12.0.0" } }, "sha512-M4uFg5pUt35OMgjD4rWWbwd6PmxV96W7r/gQTTa+iZA5B+jO6aURhzAZGLHSrg1Kb91cKG0Rildy9q1WJvYstg=="],
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
"react-native-video": ["react-native-video@github:zoriya/react-native-video#4bfa59b", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.35.0" } }, "zoriya-react-native-video-4bfa59b", "sha512-rh3YPcQz+Uj/aMeDkSzFGOjgvvYvaizC85lywrY630KcrLg2vq+0vTwNTzARjjsdNOR3BCX4Ad/J3WEI5IHaxQ=="],
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
@ -1650,6 +1658,8 @@
"@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.5" } }, "sha512-YcVcLCrf0ed4mdLa82Qob0VxYqfhmlRxUS8+TO4gosZo/gLwSvtdeOjc/Vt0pe/lvMNrBap9LlmvZM8FIsMgJQ=="],
"@react-navigation/material-top-tabs/@react-navigation/elements": ["@react-navigation/elements@2.9.14", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],

View File

@ -27,6 +27,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/material-top-tabs": "^7.4.23",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.91.3",
"@tanstack/react-query-devtools": "^5.91.3",
@ -59,6 +60,7 @@
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^4.2.0",
"react-native-nitro-modules": "^0.35.2",
"react-native-pager-view": "^8.0.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",

View File

@ -1,12 +1,7 @@
import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg";
// import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
import Home from "@material-symbols/svg-400/rounded/home-fill.svg";
import Person from "@material-symbols/svg-400/rounded/person-fill.svg";
import { Slot, Tabs } from "expo-router";
import { Slot } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Icon } from "~/primitives";
import { cn } from "~/utils";
export const unstable_settings = {
initialRouteName: "index",
@ -18,49 +13,39 @@ export default function TabsLayout() {
if (Platform.OS === "web") return <Slot />;
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
tabBarLabel: t("navbar.home"),
tabBarIcon: ({ focused }) => {
return (
<Icon
icon={Home}
className={cn(focused && "fill-accent dark:fill-accent")}
/>
);
},
}}
/>
<Tabs.Screen
name="browse"
options={{
tabBarLabel: t("navbar.browse"),
tabBarIcon: ({ focused }) => (
<Icon
icon={Browse}
className={cn(focused && "fill-accent dark:fill-accent")}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
tabBarLabel: t("navbar.profile"),
tabBarIcon: ({ focused }) => (
<Icon
icon={Person}
className={cn(focused && "fill-accent dark:fill-accent")}
/>
),
}}
/>
</Tabs>
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon
sf={{ default: "house", selected: "house.fill" }}
md="home"
/>
<NativeTabs.Trigger.Label>{t("navbar.home")}</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="browse">
<NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
md="browse"
/>
<NativeTabs.Trigger.Label>
{t("navbar.browse")}
</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile">
<NativeTabs.Trigger.Icon
sf={{ default: "person", selected: "person.fill" }}
md="person"
/>
<NativeTabs.Trigger.Label>
{t("navbar.profile")}
</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="admin">
<NativeTabs.Trigger.Icon
sf={{ default: "person.3", selected: "person.3.fill" }}
md="admin_panel_settings"
/>
<NativeTabs.Trigger.Label>{t("navbar.admin")}</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@ -0,0 +1,86 @@
import {
createMaterialTopTabNavigator,
type MaterialTopTabNavigationEventMap,
type MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import type {
NavigationProp,
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { Slot, withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useCSSVariable, useResolveClassNames } from "uniwind";
const { Navigator } = createMaterialTopTabNavigator();
const TopTabs = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
export const unstable_settings = {
initialRouteName: "unmatched",
};
export default function AdminTabsLayout() {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<ParamListBase>>();
const accent = useCSSVariable("--color-accent");
const { color: activeColor } = useResolveClassNames("text-slate-100");
const { color: inactiveColor } = useResolveClassNames("text-slate-400");
const { color: borderColor } = useResolveClassNames("border-slate-700");
useFocusEffect(
useCallback(() => {
for (let nav = navigation; nav; nav = nav.getParent()) {
nav.setOptions({ headerShadowVisible: false });
}
return () => {
for (let nav = navigation; nav; nav = nav.getParent()) {
nav.setOptions({ headerShadowVisible: undefined });
}
};
}, [navigation]),
);
if (Platform.OS === "web") return <Slot />;
return (
<TopTabs
screenOptions={{
tabBarStyle: {
backgroundColor: accent as string,
borderBottomColor: borderColor as string,
borderBottomWidth: 1,
elevation: 0,
shadowOpacity: 0,
},
tabBarIndicatorStyle: {
backgroundColor: activeColor as string,
},
tabBarActiveTintColor: activeColor as string,
tabBarInactiveTintColor: inactiveColor as string,
}}
>
<TopTabs.Screen
name="unmatched"
options={{
tabBarLabel: t("admin.unmatched.label"),
}}
/>
<TopTabs.Screen
name="users"
options={{
tabBarLabel: t("admin.users.label"),
}}
/>
</TopTabs>
);
}

View File

@ -55,5 +55,5 @@ export const OidcLogin = ({ apiUrl }: { apiUrl: string }) => {
OidcLogin.query = (apiUrl?: string): QueryIdentifier<AuthInfo> => ({
path: ["auth", "info"],
parser: AuthInfo,
options: { apiUrl },
options: { apiUrl, returnError: true },
});

View File

@ -1,18 +1,27 @@
import Admin from "@material-symbols/svg-400/rounded/admin_panel_settings.svg";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close.svg";
import Login from "@material-symbols/svg-400/rounded/login.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Person from "@material-symbols/svg-400/rounded/person-fill.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
import { useIsFocused } from "@react-navigation/native";
import { useNavigation, usePathname, useRouter } from "expo-router";
import KyooLongLogo from "public/icon-long.svg";
import { type ComponentProps, useLayoutEffect, useRef, useState } from "react";
import {
type ComponentProps,
type ComponentType,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
type PressableProps,
Text,
TextInput,
type TextInputProps,
View,
@ -30,7 +39,7 @@ import {
A,
Avatar,
HR,
HRP,
Icon,
IconButton,
Menu,
PressableFeedback,
@ -48,22 +57,53 @@ export const NavbarLeft = () => {
return (
<View className="flex-row items-center gap-4">
<NavbarTitle />
<A
href="/browse"
className="font-headers text-lg text-slate-200 uppercase dark:text-slate-200"
>
{t("navbar.browse")}
</A>
<A
<NavbarLink href="/browse" label={t("navbar.browse")} icon={Browse} />
<NavbarLink
href="/profiles/me"
className="font-headers text-lg text-slate-200 uppercase dark:text-slate-200"
>
{t("navbar.profile")}
</A>
label={t("navbar.profile")}
icon={Person}
/>
<Menu Trigger={NavbarLink} label={t("navbar.admin")} icon={Admin}>
<Menu.Item
label={t("admin.unmatched.label")}
icon={Search}
href="/admin/unmatched"
/>
<Menu.Item label="Users" icon={Admin} href="/admin/users" />
</Menu>
</View>
);
};
const NavbarLink = <AsProps = ComponentProps<typeof A>>({
as,
label,
icon,
...props
}: {
as?: ComponentType<AsProps>;
label: string;
icon: ComponentProps<typeof Icon>["icon"];
} & AsProps) => {
const As = as ?? A;
return (
<As
aria-label={label}
className="items-center justify-center"
{...tooltip(label)}
{...(props as any)}
>
<Icon
icon={icon}
className="fill-slate-200 sm:hidden dark:fill-slate-200"
/>
<Text className="font-headers text-lg text-slate-200 uppercase max-sm:hidden dark:text-slate-200">
{label}
</Text>
</As>
);
};
export const NavbarTitle = ({
className,
...props
@ -260,17 +300,6 @@ export const NavbarProfile = () => {
/>
</>
)}
{account?.isAdmin && (
<>
<HRP text={t("navbar.admin")} />
<Menu.Item
label={t("admin.unmatched.label")}
icon={Search}
href="/unmatched"
/>
<Menu.Item label="Users" icon={Admin} href="/admin/users" />
</>
)}
</Menu>
);
};