Compare commits

..

1 Commits

Author SHA1 Message Date
Adrian Castro
9694630cdf Merge 3fb2567ae1 into a3f184979e 2024-04-18 15:34:48 +00:00
37 changed files with 194 additions and 1069 deletions

View File

@@ -1,4 +1,3 @@
import "expo-router/entry";
import "react-native-gesture-handler";
import "@react-native-anywhere/polyfill-base64";
import "text-encoding-polyfill";

View File

@@ -41,7 +41,6 @@
"expo-av": "~13.10.5",
"expo-brightness": "~11.8.0",
"expo-build-properties": "~0.11.1",
"expo-clipboard": "^5.0.1",
"expo-constants": "~15.4.5",
"expo-file-system": "~16.0.8",
"expo-haptics": "~12.8.1",
@@ -76,7 +75,6 @@
"react-native-web": "^0.19.10",
"subsrt-ts": "^2.1.2",
"tamagui": "^1.94.0",
"text-encoding-polyfill": "^0.6.7",
"zustand": "^4.4.7"
},
"devDependencies": {

View File

@@ -55,7 +55,7 @@ const TestDownloadButton = (props: {
<MaterialCommunityIcons
name="download"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={async () => {

View File

@@ -1,5 +1,5 @@
import { Link } from "expo-router";
import { H3, H5, Paragraph, View } from "tamagui";
import { H2, H5, Paragraph, View } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
@@ -18,15 +18,15 @@ export default function MovieWebScreen() {
justifyContent: "center",
}}
>
<MWCard bordered padded>
<MWCard.Header>
<H3 fontWeight="$bold" paddingBottom="$1">
<MWCard bordered>
<MWCard.Header padded>
<H2 fontWeight="$bold" paddingBottom="$1">
Sync to the cloud
</H3>
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
</H2>
<H5 color="$ash50" fontWeight="$semibold" paddingVertical="$3">
Share your watch progress between devices and keep them synced.
</H5>
<Paragraph color="$shade200">
<Paragraph color="$ash50">
First choose the backend you want to use. If you do not know what
this does, use the default and click on &apos;Get started&apos;.
</Paragraph>
@@ -35,24 +35,24 @@ export default function MovieWebScreen() {
<View padding="$4">
<MWInput
placeholder={backendUrl}
type="authentication"
type="search"
value={backendUrl}
onChangeText={setBackendUrl}
/>
</View>
<MWCard.Footer padded justifyContent="center">
<MWButton type="purple">
<Link
href={{
pathname: "/sync/trust/[url]",
params: { url: backendUrl },
}}
asChild
style={{ color: "white", fontWeight: "bold" }}
>
<MWButton type="purple" width="100%">
Get started
</MWButton>
</Link>
</MWButton>
</MWCard.Footer>
</MWCard>
</ScreenLayout>

View File

@@ -206,7 +206,7 @@ export default function SettingsScreen() {
android: "android",
})}
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
iconAfter={
@@ -229,7 +229,7 @@ export default function SettingsScreen() {
<MaterialCommunityIcons
name="broom"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => clearCacheDirectory()}
@@ -304,7 +304,7 @@ export function UpdateSheet({
<MaterialCommunityIcons
name={Platform.select({ ios: "apple", android: "android" })}
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}

View File

@@ -1,79 +0,0 @@
import { Stack } from "expo-router";
import { H4, Label, Paragraph, Text, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
export default function Page() {
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Login to your account
</H4>
<Paragraph
color="$ash50"
textAlign="center"
fontWeight="$semibold"
paddingVertical="$4"
>
Please enter your passphrase to login to your account
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">12-Word passphrase</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Device name</Label>
<MWInput
type="authentication"
placeholder="Personal phone"
autoCorrect={false}
/>
</YStack>
</YStack>
<MWCard.Footer
padded
justifyContent="center"
flexDirection="column"
gap="$4"
>
<MWButton type="purple">Login</MWButton>
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Don&apos;t have an account yet?{"\n"}
<Text color="$purple100" fontWeight="$bold">
Create an account.
</Text>
</Paragraph>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -1,190 +0,0 @@
import { useState } from "react";
import { Link, Stack } from "expo-router";
import { FontAwesome6, Ionicons } from "@expo/vector-icons";
import { Circle, H4, Label, Paragraph, View, XStack, YStack } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"] as const;
function ColorPicker(props: {
value: (typeof colors)[number];
onInput: (v: (typeof colors)[number]) => void;
}) {
return (
<XStack gap="$2">
{colors.map((color) => {
return (
<View
onPress={() => props.onInput(color)}
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={color}
key={color}
>
{props.value === color ? (
<Ionicons name="checkmark-circle" size={24} color="white" />
) : null}
</View>
);
})}
</XStack>
);
}
const icons = [
"user-group",
"couch",
"mobile-screen",
"ticket",
"handcuffs",
] as const;
function UserIconPicker(props: {
value: (typeof icons)[number];
onInput: (v: (typeof icons)[number]) => void;
}) {
return (
<XStack gap="$2">
{icons.map((icon) => {
return (
<View
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
borderColor={props.value === icon ? "$purple200" : "$shade400"}
borderWidth={1}
key={icon}
onPress={() => props.onInput(icon)}
>
<FontAwesome6 name={icon} size={24} color="white" />
</View>
);
})}
</XStack>
);
}
interface AvatarProps {
colorA: string;
colorB: string;
icon: (typeof icons)[number];
}
export function Avatar(props: AvatarProps) {
return (
<Circle
backgroundColor={props.colorA}
height="$6"
width="$6"
justifyContent="center"
alignItems="center"
>
<LinearGradient
colors={[props.colorA, props.colorB]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
borderRadius="$12"
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<FontAwesome6 name={props.icon} size={24} color="white" />
</LinearGradient>
</Circle>
);
}
export default function Page() {
const [color, setColor] = useState<(typeof colors)[number]>(colors[0]);
const [color2, setColor2] = useState<(typeof colors)[number]>(colors[0]);
const [icon, setIcon] = useState<(typeof icons)[number]>(icons[0]);
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<View alignItems="center" marginBottom="$3">
<Avatar colorA={color} colorB={color2} icon={icon} />
</View>
<H4 fontWeight="$bold" textAlign="center">
Account information
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Enter a name for your device and pick colours and a user icon of
your choosing
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">Device name</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color one</Label>
<ColorPicker value={color} onInput={(color) => setColor(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color two</Label>
<ColorPicker value={color2} onInput={(color) => setColor2(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">User icon</Label>
<UserIconPicker value={icon} onInput={(icon) => setIcon(icon)} />
</YStack>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/sync/register/confirm",
}}
replace
asChild
>
<MWButton type="purple" width="100%">
Next
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -1,68 +0,0 @@
import { Link, Stack } from "expo-router";
import { H4, Label, Paragraph, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
export default function Page() {
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Confirm your passphrase
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Please enter your passphrase from earlier to confirm you have saved
it and to create your account
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">12-Word passphrase</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/(tabs)/movie-web",
}}
replace
asChild
>
<MWButton type="purple">Create account</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -1,132 +0,0 @@
import { TouchableOpacity } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { Link, Stack } from "expo-router";
import { Feather } from "@expo/vector-icons";
import { H4, Paragraph, Text, useTheme, View, XStack, YStack } from "tamagui";
import { genMnemonic } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
function PassphraseWord({ word }: { word: string }) {
return (
<View
width="$10"
borderRadius="$4"
paddingHorizontal="$4"
paddingVertical="$3"
alignItems="center"
justifyContent="center"
backgroundColor="$shade400"
>
<Text fontWeight="$bold">{word}</Text>
</View>
);
}
export default function Page() {
const theme = useTheme();
const words = genMnemonic().split(" ");
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Your passphrase
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Your passphrase acts as your username and password. Make sure to
keep it safe as you will need to enter it to login to your account
</Paragraph>
</MWCard.Header>
<YStack
borderRadius="$4"
borderColor="$shade200"
borderWidth="$0.5"
marginBottom="$4"
>
<XStack
gap="$1"
borderBottomWidth="$0.5"
borderColor="$shade200"
paddingVertical="$2"
paddingHorizontal="$4"
>
<Text fontWeight="$bold" flexGrow={1}>
Passphrase
</Text>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
onPress={async () => {
await Clipboard.setStringAsync(words.join(""));
}}
>
<Feather name="copy" size={18} color={theme.shade200.val} />
<Text color="$shade200" fontWeight="$bold">
Copy
</Text>
</TouchableOpacity>
</XStack>
<View
flexWrap="wrap"
flexDirection="row"
gap="$4"
alignItems="center"
justifyContent="center"
padding="$3"
>
{words.map((word, index) => (
<PassphraseWord key={index} word={word} />
))}
</View>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/sync/register/account",
}}
asChild
>
<MWButton type="purple">I have saved my passphrase</MWButton>
</Link>
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Already have an account?{"\n"}
<Text color="$purple100" fontWeight="$bold">
<Link href="/sync/login">Login here</Link>
</Text>
</Paragraph>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -1,19 +1,30 @@
import { Link, Stack, useLocalSearchParams } from "expo-router";
import { Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { H4, Paragraph, Text, View } from "tamagui";
import { getBackendMeta } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
// TODO: extract to function with cleanup and types
const getBackendMeta = (
url: string,
): Promise<{
description: string;
hasCaptcha: boolean;
name: string;
url: string;
}> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return fetch(`${url}/meta`).then((res) => res.json());
};
export default function Page() {
const { url } = useLocalSearchParams<{ url: string }>();
const { url } = useLocalSearchParams();
const meta = useQuery({
queryKey: ["backendMeta", url],
queryFn: () => getBackendMeta(url),
queryFn: () => getBackendMeta(url as string),
});
return (
@@ -30,7 +41,7 @@ export default function Page() {
title: "",
}}
/>
<MWCard bordered padded>
<MWCard bordered>
<MWCard.Header padded>
<H4 fontWeight="$bold" textAlign="center">
Do you trust this server?
@@ -89,24 +100,16 @@ export default function Page() {
justifyContent="center"
flexDirection="column"
gap="$4"
>
<Link
href={{
pathname: "/sync/register",
}}
asChild
>
<MWButton type="purple">I trust this server</MWButton>
</Link>
<Link
href={{
pathname: "/(tabs)/",
}}
replace
asChild
>
<MWButton type="cancel">Go back</MWButton>
</Link>
<MWButton type="secondary">Go back</MWButton>
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Already have an account?{" "}
<Text color="$purple100" fontWeight="$bold">
Login here
</Text>
</Paragraph>
</MWCard.Footer>
</MWCard>
</ScreenLayout>

View File

@@ -58,7 +58,7 @@ export const AudioTrackSelector = () => {
<MaterialCommunityIcons
name="volume-high"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}

View File

@@ -65,7 +65,7 @@ export const CaptionsSelector = () => {
<MaterialCommunityIcons
name="subtitles"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}

View File

@@ -38,7 +38,7 @@ export const DownloadButton = () => {
<MaterialCommunityIcons
name="download"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() =>

View File

@@ -47,7 +47,7 @@ const EpisodeSelector = ({
<Ionicons
name="arrow-back"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
onPress={() => {
setSelectedSeason(null);
props.onOpenChange?.(false);
@@ -119,7 +119,7 @@ export const SeasonSelector = () => {
<MaterialCommunityIcons
name="audio-video"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}

View File

@@ -23,7 +23,7 @@ export const SettingsSelector = () => {
<MaterialIcons
name="display-settings"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}

View File

@@ -102,7 +102,7 @@ const EmbedsPart = ({
<Ionicons
name="arrow-back"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
onPress={() => {
props.onOpenChange?.(false);
}}
@@ -160,7 +160,7 @@ export const SourceSelector = () => {
<MaterialCommunityIcons
name="video"
size={24}
color={theme.silver300.val}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}

View File

@@ -4,35 +4,35 @@ export const MWButton = styled(Button, {
variants: {
type: {
primary: {
backgroundColor: "white",
color: "black",
backgroundColor: "$buttonPrimaryBackground",
color: "$buttonPrimaryText",
fontWeight: "bold",
pressStyle: {
backgroundColor: "$silver100",
backgroundColor: "$buttonPrimaryBackgroundHover",
},
},
secondary: {
backgroundColor: "$ash700",
color: "$silver300",
backgroundColor: "$buttonSecondaryBackground",
color: "$buttonSecondaryText",
fontWeight: "bold",
pressStyle: {
backgroundColor: "$ash500",
backgroundColor: "$buttonSecondaryBackgroundHover",
},
},
purple: {
backgroundColor: "$purple500",
backgroundColor: "$buttonPurpleBackground",
color: "white",
fontWeight: "bold",
pressStyle: {
backgroundColor: "$purple400",
backgroundColor: "$buttonPurpleBackgroundHover",
},
},
cancel: {
backgroundColor: "$ash500",
backgroundColor: "$buttonCancelBackground",
color: "white",
fontWeight: "bold",
pressStyle: {
backgroundColor: "$ash300",
backgroundColor: "$buttonCancelBackgroundHover",
},
},
},

View File

@@ -1,7 +1,7 @@
import { Card, styled, withStaticProperties } from "tamagui";
export const MWCardFrame = styled(Card, {
backgroundColor: "$shade600",
backgroundColor: "$shade400",
borderColor: "$shade400",
variants: {

View File

@@ -6,37 +6,21 @@ export const MWInput = styled(Input, {
variants: {
type: {
default: {
backgroundColor: "$ash600",
color: "$ash100",
placeholderTextColor: "$ash200",
borderColor: "$ash500",
backgroundColor: "$inputBackground",
color: "$inputText",
placeholderTextColor: "$placeHolderText",
borderColor: "$inputBorder",
outlineStyle: "none",
focusStyle: {
borderColor: "$ash300",
},
},
search: {
backgroundColor: "$shade500",
color: "$shade100",
backgroundColor: "$searchBackground",
borderColor: "$colorTransparent",
placeholderTextColor: "$shade100",
placeholderTextColor: "$searchPlaceholder",
outlineStyle: "none",
focusStyle: {
borderColor: "$colorTransparent",
},
},
authentication: {
backgroundColor: "$shade500",
color: "$shade100",
placeholderTextColor: "$shade400",
outlineStyle: "none",
focusStyle: {
borderColor: "$shade300",
},
pressStyle: {
backgroundColor: "$shade500",
},
},
},
},
});

View File

@@ -1,204 +0,0 @@
import { useCallback } from "react";
import type {
AccountWithToken,
BookmarkMediaItem,
ProgressMediaItem,
SessionResponse,
UserResponse,
} from "@movie-web/api";
import {
bookmarkMediaToInput,
bytesToBase64,
bytesToBase64Url,
encryptData,
getBookmarks,
getLoginChallengeToken,
getProgress,
getRegisterChallengeToken,
getSettings,
getUser,
importBookmarks,
importProgress,
keysFromMnemonic,
loginAccount,
progressMediaItemToInputs,
registerAccount,
removeSession,
signChallenge,
} from "@movie-web/api";
import { useAuthStore } from "~/stores/settings";
import { useAuthData } from "./useAuthData";
export interface RegistrationData {
recaptchaToken?: string;
mnemonic: string;
userData: {
device: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
};
}
export interface LoginData {
mnemonic: string;
userData: {
device: string;
};
}
export function useAuth() {
const currentAccount = useAuthStore((s) => s.account);
const profile = useAuthStore((s) => s.account?.profile);
const loggedIn = !!useAuthStore((s) => s.account);
const backendUrl = useAuthStore((s) => s.backendUrl);
const {
logout: userDataLogout,
login: userDataLogin,
syncData,
} = useAuthData();
const login = useCallback(
async (loginData: LoginData) => {
if (!backendUrl) return;
const keys = await keysFromMnemonic(loginData.mnemonic);
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
const { challenge } = await getLoginChallengeToken(
backendUrl,
publicKeyBase64Url,
);
const signature = signChallenge(keys, challenge);
const loginResult = await loginAccount(backendUrl, {
challenge: {
code: challenge,
signature,
},
publicKey: publicKeyBase64Url,
device: await encryptData(loginData.userData.device, keys.seed),
});
const user = await getUser(backendUrl, loginResult.token);
const seedBase64 = bytesToBase64(keys.seed);
return userDataLogin(loginResult, user.user, user.session, seedBase64);
},
[userDataLogin, backendUrl],
);
const logout = useCallback(async () => {
if (!currentAccount || !backendUrl) return;
try {
await removeSession(
backendUrl,
currentAccount.token,
currentAccount.sessionId,
);
} catch {
// we dont care about failing to delete session
}
userDataLogout();
}, [userDataLogout, backendUrl, currentAccount]);
const register = useCallback(
async (registerData: RegistrationData) => {
if (!backendUrl) return;
const { challenge } = await getRegisterChallengeToken(
backendUrl,
registerData.recaptchaToken,
);
const keys = await keysFromMnemonic(registerData.mnemonic);
const signature = signChallenge(keys, challenge);
const registerResult = await registerAccount(backendUrl, {
challenge: {
code: challenge,
signature,
},
publicKey: bytesToBase64Url(keys.publicKey),
device: await encryptData(registerData.userData.device, keys.seed),
profile: registerData.userData.profile,
});
return userDataLogin(
registerResult,
registerResult.user,
registerResult.session,
bytesToBase64(keys.seed),
);
},
[backendUrl, userDataLogin],
);
const importData = useCallback(
async (
account: AccountWithToken,
progressItems: Record<string, ProgressMediaItem>,
bookmarks: Record<string, BookmarkMediaItem>,
) => {
if (!backendUrl) return;
if (
Object.keys(progressItems).length === 0 &&
Object.keys(bookmarks).length === 0
) {
return;
}
const progressInputs = Object.entries(progressItems).flatMap(
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
);
const bookmarkInputs = Object.entries(bookmarks).map(([tmdbId, item]) =>
bookmarkMediaToInput(tmdbId, item),
);
await Promise.all([
importProgress(backendUrl, account, progressInputs),
importBookmarks(backendUrl, account, bookmarkInputs),
]);
},
[backendUrl],
);
const restore = useCallback(
async (account: AccountWithToken) => {
if (!backendUrl) return;
let user: { user: UserResponse; session: SessionResponse };
try {
user = await getUser(backendUrl, account.token);
} catch (err) {
const anyError = err as { response?: { status: number } };
if (
anyError?.response?.status === 401 ||
anyError?.response?.status === 403 ||
anyError?.response?.status === 400
) {
await logout();
return;
}
console.error(err);
throw err;
}
const [bookmarks, progress, settings] = await Promise.all([
getBookmarks(backendUrl, account),
getProgress(backendUrl, account),
getSettings(backendUrl, account),
]);
syncData(user.user, user.session, progress, bookmarks, settings);
},
[backendUrl, syncData, logout],
);
return {
loggedIn,
profile,
login,
logout,
register,
restore,
importData,
};
}

View File

@@ -1,170 +0,0 @@
import { useCallback } from "react";
import type {
BookmarkResponse,
LoginResponse,
ProgressResponse,
SessionResponse,
SettingsResponse,
UserResponse,
} from "@movie-web/api";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import type { ItemData } from "~/components/item/item";
import type { WatchHistoryItem } from "~/stores/settings";
import type { ThemeStoreOption } from "~/stores/theme";
import {
useAuthStore,
useBookmarkStore,
useWatchHistoryStore,
} from "~/stores/settings";
import { useThemeStore } from "~/stores/theme";
export function useAuthData() {
const loggedIn = !!useAuthStore((s) => s.account);
const setAccount = useAuthStore((s) => s.setAccount);
const removeAccount = useAuthStore((s) => s.removeAccount);
const setBookmarks = useBookmarkStore((s) => s.setBookmarks);
const setProxySet = useAuthStore((s) => s.setProxySet);
const setWatchHistory = useWatchHistoryStore((s) => s.setWatchHistory);
const clearBookmarks = useCallback(() => setBookmarks([]), [setBookmarks]);
const clearProgress = useCallback(
() => setWatchHistory([]),
[setWatchHistory],
);
const replaceBookmarks = useCallback(
(bookmarks: ItemData[]) => setBookmarks(bookmarks),
[setBookmarks],
);
const replaceItems = useCallback(
(items: WatchHistoryItem[]) => setWatchHistory(items),
[setWatchHistory],
);
const setTheme = useThemeStore((s) => s.setTheme);
// const setAppLanguage = useLanguageStore((s) => s.setLanguage);
// const importSubtitleLanguage = useSubtitleStore(
// (s) => s.importSubtitleLanguage,
// );
const login = useCallback(
(
loginResponse: LoginResponse,
user: UserResponse,
session: SessionResponse,
seed: string,
) => {
const account = {
token: loginResponse.token,
userId: user.id,
sessionId: loginResponse.session.id,
deviceName: session.device,
profile: user.profile,
seed,
};
setAccount(account);
return account;
},
[setAccount],
);
const logout = useCallback(() => {
removeAccount();
clearBookmarks();
clearProgress();
}, [removeAccount, clearBookmarks, clearProgress]);
const syncData = useCallback(
(
_user: UserResponse,
_session: SessionResponse,
progress: ProgressResponse[],
bookmarks: BookmarkResponse[],
settings: SettingsResponse,
) => {
const bookmarkResponseToItemData = (
bookmarks: BookmarkResponse[],
): ItemData[] => {
return bookmarks.map((bookmark) => ({
id: bookmark.tmdbId,
title: bookmark.meta.title,
type: bookmark.meta.type === "show" ? "tv" : "movie",
year: bookmark.meta.year,
posterUrl: bookmark.meta.poster ?? "",
}));
};
const progressResponseToWatchHistoryItem = (
progress: ProgressResponse[],
): WatchHistoryItem[] => {
return progress.map((entry) => {
const isShow = entry.meta.type === "show";
const commonMedia = {
title: entry.meta.title,
releaseYear: entry.meta.year,
tmdbId: entry.tmdbId,
};
const media: ScrapeMedia = isShow
? {
...commonMedia,
type: "show",
season: {
number: entry.season.number ?? 0,
tmdbId: entry.season.id ?? "",
},
episode: {
number: entry.episode.number ?? 0,
tmdbId: entry.episode.id ?? "",
},
}
: {
...commonMedia,
type: "movie",
};
return {
item: {
id: entry.tmdbId,
title: entry.meta.title,
type: entry.meta.type === "show" ? "tv" : "movie",
season: entry.season.number,
episode: entry.episode.number,
year: entry.meta.year,
posterUrl: entry.meta.poster ?? "",
},
media: media,
positionMillis: parseInt(entry.watched, 10),
};
});
};
replaceBookmarks(bookmarkResponseToItemData(bookmarks));
replaceItems(progressResponseToWatchHistoryItem(progress));
// if (settings.applicationLanguage) {
// setAppLanguage(settings.applicationLanguage);
// }
// if (settings.defaultSubtitleLanguage) {
// importSubtitleLanguage(settings.defaultSubtitleLanguage);
// }
if (settings.applicationTheme) {
setTheme(settings.applicationTheme as unknown as ThemeStoreOption);
}
if (settings.proxyUrls) {
setProxySet(settings.proxyUrls);
}
},
[replaceBookmarks, replaceItems, setTheme, setProxySet],
);
return {
loggedIn,
login,
logout,
syncData,
};
}

View File

@@ -144,7 +144,7 @@ export const useBookmarkStore = create<
),
);
export interface WatchHistoryItem {
interface WatchHistoryItem {
item: ItemData;
media: ScrapeMedia;
positionMillis: number;

View File

@@ -57,6 +57,17 @@ const createThemeConfig = (tokens: Tokens) => ({
loadingIndicator: tokens.purple.c200,
buttonSecondaryBackground: tokens.ash.c700,
buttonSecondaryText: tokens.semantic.silver.c300,
buttonSecondaryBackgroundHover: tokens.ash.c700,
buttonPrimaryBackground: tokens.white,
buttonPrimaryText: tokens.black,
buttonPrimaryBackgroundHover: tokens.semantic.silver.c100,
buttonPurpleBackground: tokens.purple.c500,
buttonPurpleBackgroundHover: tokens.purple.c400,
buttonCancelBackground: tokens.ash.c500,
buttonCancelBackgroundHover: tokens.ash.c300,
switchActiveTrackColor: tokens.purple.c300,
switchInactiveTrackColor: tokens.ash.c500,
switchThumbColor: tokens.white,

View File

@@ -32,6 +32,7 @@
"dependencies": {
"@noble/hashes": "^1.4.0",
"@scure/bip39": "^1.3.0",
"node-forge": "^1.3.1"
"node-forge": "^1.3.1",
"ofetch": "^1.3.4"
}
}

View File

@@ -1,5 +1,6 @@
import { ofetch } from "ofetch";
import type { LoginResponse } from "./types";
import { f } from "./fetch";
export function getAuthHeaders(token: string): Record<string, string> {
return {
@@ -12,12 +13,12 @@ export async function accountLogin(
id: string,
deviceName: string,
): Promise<LoginResponse> {
return f<LoginResponse>("/auth/login", {
return ofetch<LoginResponse>("/auth/login", {
method: "POST",
body: {
id,
device: deviceName,
},
baseUrl: url,
baseURL: url,
});
}

View File

@@ -1,3 +1,5 @@
import { ofetch } from "ofetch";
import type {
AccountWithToken,
BookmarkInput,
@@ -5,7 +7,6 @@ import type {
BookmarkResponse,
} from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export function bookmarkMediaToInput(
tmdbId: string,
@@ -27,12 +28,12 @@ export async function addBookmark(
account: AccountWithToken,
input: BookmarkInput,
) {
return f<BookmarkResponse>(
return ofetch<BookmarkResponse>(
`/users/${account.userId}/bookmarks/${input.tmdbId}`,
{
method: "POST",
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
body: input,
},
);
@@ -43,9 +44,12 @@ export async function removeBookmark(
account: AccountWithToken,
id: string,
) {
return f<{ tmdbId: string }>(`/users/${account.userId}/bookmarks/${id}`, {
return ofetch<{ tmdbId: string }>(
`/users/${account.userId}/bookmarks/${id}`,
{
method: "DELETE",
headers: getAuthHeaders(account.token),
baseUrl: url,
});
baseURL: url,
},
);
}

View File

@@ -39,7 +39,11 @@ export function genMnemonic(): string {
return generateMnemonic(wordlist);
}
export function signCode(code: string, privateKey: Uint8Array): Uint8Array {
// eslint-disable-next-line @typescript-eslint/require-await
export async function signCode(
code: string,
privateKey: Uint8Array,
): Promise<Uint8Array> {
return forge.pki.ed25519.sign({
encoding: "utf8",
message: code,
@@ -58,8 +62,8 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
.replace(/=+$/, "");
}
export function signChallenge(keys: Keys, challengeCode: string) {
const signature = signCode(challengeCode, keys.privateKey);
export async function signChallenge(keys: Keys, challengeCode: string) {
const signature = await signCode(challengeCode, keys.privateKey);
return bytesToBase64Url(signature);
}

View File

@@ -1,53 +0,0 @@
export interface FetcherOptions {
baseUrl?: string;
headers?: Record<string, string>;
query?: Record<string, string>;
method?: "HEAD" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
readHeaders?: string[];
body?: Record<string, any>;
}
export type FullUrlOptions = Pick<FetcherOptions, "query" | "baseUrl">;
export function makeFullUrl(url: string, ops?: FullUrlOptions): string {
// glue baseUrl and rest of url together
let leftSide = ops?.baseUrl ?? "";
let rightSide = url;
// left side should always end with slash, if its set
if (leftSide.length > 0 && !leftSide.endsWith("/")) leftSide += "/";
// right side should never start with slash
if (rightSide.startsWith("/")) rightSide = rightSide.slice(1);
const fullUrl = leftSide + rightSide;
if (!fullUrl.startsWith("http://") && !fullUrl.startsWith("https://"))
throw new Error(
`Invald URL -- URL doesn't start with a http scheme: '${fullUrl}'`,
);
const parsedUrl = new URL(fullUrl);
Object.entries(ops?.query ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v);
});
return parsedUrl.toString();
}
export async function f<T>(url: string, ops?: FetcherOptions): Promise<T> {
const fullUrl = makeFullUrl(url, ops);
const response = await fetch(fullUrl, {
method: ops?.method ?? "GET",
headers: ops?.headers,
body: ops?.body ? JSON.stringify(ops.body) : undefined,
});
if (!response.ok) {
throw new Error(
`Failed to fetch '${fullUrl}' -- ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as T;
return data;
}

View File

@@ -1,16 +1,17 @@
import { ofetch } from "ofetch";
import type { AccountWithToken, BookmarkInput, ProgressInput } from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export function importProgress(
url: string,
account: AccountWithToken,
progressItems: ProgressInput[],
) {
return f<void>(`/users/${account.userId}/progress/import`, {
return ofetch<void>(`/users/${account.userId}/progress/import`, {
method: "PUT",
body: progressItems,
baseUrl: url,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}
@@ -20,10 +21,10 @@ export function importBookmarks(
account: AccountWithToken,
bookmarks: BookmarkInput[],
) {
return f<void>(`/users/${account.userId}/bookmarks`, {
return ofetch<void>(`/users/${account.userId}/bookmarks`, {
method: "PUT",
body: bookmarks,
baseUrl: url,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}

View File

@@ -1,20 +1,21 @@
import { ofetch } from "ofetch";
import type {
ChallengeTokenResponse,
LoginInput,
LoginResponse,
} from "./types";
import { f } from "./fetch";
export async function getLoginChallengeToken(
url: string,
publicKey: string,
): Promise<ChallengeTokenResponse> {
return f<ChallengeTokenResponse>("/auth/login/start", {
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
method: "POST",
body: {
publicKey,
},
baseUrl: url,
baseURL: url,
});
}
@@ -22,12 +23,12 @@ export async function loginAccount(
url: string,
data: LoginInput,
): Promise<LoginResponse> {
return f<LoginResponse>("/auth/login/complete", {
return ofetch<LoginResponse>("/auth/login/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseUrl: url,
baseURL: url,
});
}

View File

@@ -1,8 +1,9 @@
import type { MetaResponse } from "./types";
import { f } from "./fetch";
import { ofetch } from "ofetch";
export function getBackendMeta(url: string): Promise<MetaResponse> {
return f<MetaResponse>("/meta", {
baseUrl: url,
import type { MetaResponse } from "./types";
export async function getBackendMeta(url: string): Promise<MetaResponse> {
return ofetch<MetaResponse>("/meta", {
baseURL: url,
});
}

View File

@@ -1,3 +1,5 @@
import { ofetch } from "ofetch";
import type {
AccountWithToken,
ProgressInput,
@@ -6,7 +8,6 @@ import type {
ProgressUpdateItem,
} from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export function progressUpdateItemToInput(
item: ProgressUpdateItem,
@@ -71,12 +72,12 @@ export async function setProgress(
account: AccountWithToken,
input: ProgressInput,
) {
return f<ProgressResponse>(
return ofetch<ProgressResponse>(
`/users/${account.userId}/progress/${input.tmdbId}`,
{
method: "PUT",
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
body: input,
},
);
@@ -89,10 +90,10 @@ export async function removeProgress(
episodeId?: string,
seasonId?: string,
) {
await f(`/users/${account.userId}/progress/${id}`, {
await ofetch(`/users/${account.userId}/progress/${id}`, {
method: "DELETE",
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
body: {
episodeId,
seasonId,

View File

@@ -1,21 +1,22 @@
import { ofetch } from "ofetch";
import type {
ChallengeTokenResponse,
RegisterInput,
SessionResponse,
UserResponse,
} from "./types";
import { f } from "./fetch";
export async function getRegisterChallengeToken(
url: string,
captchaToken?: string,
): Promise<ChallengeTokenResponse> {
return f<ChallengeTokenResponse>("/auth/register/start", {
return ofetch<ChallengeTokenResponse>("/auth/register/start", {
method: "POST",
body: {
captchaToken,
},
baseUrl: url,
baseURL: url,
});
}
@@ -29,12 +30,12 @@ export async function registerAccount(
url: string,
data: RegisterInput,
): Promise<RegisterResponse> {
return f<RegisterResponse>("/auth/register/complete", {
return ofetch<RegisterResponse>("/auth/register/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseUrl: url,
baseURL: url,
});
}

View File

@@ -1,11 +1,12 @@
import { ofetch } from "ofetch";
import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export async function getSessions(url: string, account: AccountWithToken) {
return f<SessionResponse[]>(`/users/${account.userId}/sessions`, {
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
});
}
@@ -14,11 +15,11 @@ export async function updateSession(
account: AccountWithToken,
update: SessionUpdate,
) {
return f<SessionResponse[]>(`/sessions/${account.sessionId}`, {
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
method: "PATCH",
headers: getAuthHeaders(account.token),
body: update,
baseUrl: url,
baseURL: url,
});
}
@@ -27,9 +28,9 @@ export async function removeSession(
token: string,
sessionId: string,
) {
return f<SessionResponse[]>(`/sessions/${sessionId}`, {
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
method: "DELETE",
headers: getAuthHeaders(token),
baseUrl: url,
baseURL: url,
});
}

View File

@@ -1,28 +1,29 @@
import { ofetch } from "ofetch";
import type {
AccountWithToken,
SettingsInput,
SettingsResponse,
} from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export function updateSettings(
url: string,
account: AccountWithToken,
settings: SettingsInput,
) {
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
method: "PUT",
body: settings,
baseUrl: url,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}
export function getSettings(url: string, account: AccountWithToken) {
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
method: "GET",
baseUrl: url,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}

View File

@@ -1,3 +1,5 @@
import { ofetch } from "ofetch";
import type {
AccountWithToken,
BookmarkMediaItem,
@@ -9,7 +11,6 @@ import type {
UserResponse,
} from "./types";
import { getAuthHeaders } from "./auth";
import { f } from "./fetch";
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
const entries = responses.map((bookmark) => {
@@ -82,10 +83,13 @@ export async function getUser(
url: string,
token: string,
): Promise<{ user: UserResponse; session: SessionResponse }> {
return f<{ user: UserResponse; session: SessionResponse }>("/users/@me", {
return ofetch<{ user: UserResponse; session: SessionResponse }>(
"/users/@me",
{
headers: getAuthHeaders(token),
baseUrl: url,
});
baseURL: url,
},
);
}
export async function editUser(
@@ -93,13 +97,13 @@ export async function editUser(
account: AccountWithToken,
object: UserEdit,
): Promise<{ user: UserResponse; session: SessionResponse }> {
return f<{ user: UserResponse; session: SessionResponse }>(
return ofetch<{ user: UserResponse; session: SessionResponse }>(
`/users/${account.userId}`,
{
method: "PATCH",
headers: getAuthHeaders(account.token),
body: object,
baseUrl: url,
baseURL: url,
},
);
}
@@ -108,22 +112,22 @@ export async function deleteUser(
url: string,
account: AccountWithToken,
): Promise<UserResponse> {
return f<UserResponse>(`/users/${account.userId}`, {
return ofetch<UserResponse>(`/users/${account.userId}`, {
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
});
}
export async function getBookmarks(url: string, account: AccountWithToken) {
return f<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
});
}
export async function getProgress(url: string, account: AccountWithToken) {
return f<ProgressResponse[]>(`/users/${account.userId}/progress`, {
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
headers: getAuthHeaders(account.token),
baseUrl: url,
baseURL: url,
});
}

41
pnpm-lock.yaml generated
View File

@@ -95,9 +95,6 @@ importers:
expo-build-properties:
specifier: ~0.11.1
version: 0.11.1(expo@50.0.14)
expo-clipboard:
specifier: ^5.0.1
version: 5.0.1(expo@50.0.14)
expo-constants:
specifier: ~15.4.5
version: 15.4.5(expo@50.0.14)
@@ -200,9 +197,6 @@ importers:
tamagui:
specifier: ^1.94.0
version: 1.94.0(@types/react@18.2.52)(immer@10.0.3)(react-dom@18.2.0)(react-native-web@0.19.10)(react-native@0.73.6)(react@18.2.0)
text-encoding-polyfill:
specifier: ^0.6.7
version: 0.6.7
zustand:
specifier: ^4.4.7
version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0)
@@ -258,6 +252,9 @@ importers:
node-forge:
specifier: ^1.3.1
version: 1.3.1
ofetch:
specifier: ^1.3.4
version: 1.3.4
devDependencies:
'@movie-web/eslint-config':
specifier: workspace:^0.2.0
@@ -7118,6 +7115,10 @@ packages:
minimalistic-assert: 1.0.1
dev: false
/destr@2.0.3:
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
dev: false
/destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -7863,14 +7864,6 @@ packages:
semver: 7.5.4
dev: false
/expo-clipboard@5.0.1(expo@50.0.14):
resolution: {integrity: sha512-JH853QJPr5W3h87If3aDTnMK+ESSIrwzU2TdfZrqZttVDY2pMIf/w37mVHHNYodXM4ATHXadtOkjKbAa0DWwUg==}
peerDependencies:
expo: '*'
dependencies:
expo: 50.0.14(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
dev: false
/expo-constants@15.4.5(expo@50.0.14):
resolution: {integrity: sha512-1pVVjwk733hbbIjtQcvUFCme540v4gFemdNlaxM2UXKbfRCOh2hzgKN5joHMOysoXQe736TTUrRj7UaZI5Yyhg==}
peerDependencies:
@@ -10552,6 +10545,10 @@ packages:
engines: {node: '>=10.5.0'}
dev: false
/node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
dev: false
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -10730,6 +10727,14 @@ packages:
es-abstract: 1.22.3
dev: false
/ofetch@1.3.4:
resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==}
dependencies:
destr: 2.0.3
node-fetch-native: 1.6.4
ufo: 1.5.3
dev: false
/on-finished@2.3.0:
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
engines: {node: '>= 0.8'}
@@ -13078,10 +13083,6 @@ packages:
source-map-support: 0.5.21
dev: false
/text-encoding-polyfill@0.6.7:
resolution: {integrity: sha512-/DZ1XJqhbqRkCop6s9ZFu8JrFRwmVuHg4quIRm+ziFkR3N3ec6ck6yBvJ1GYeEQZhLVwRW0rZE+C3SSJpy0RTg==}
dev: false
/text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -13381,6 +13382,10 @@ packages:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
dev: false
/ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
dev: false
/uglify-js@3.17.4:
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
engines: {node: '>=0.8.0'}