Compare commits

..

7 Commits

Author SHA1 Message Date
Adrian Castro
0f3ccbcabc Merge 9eb9fb494c into a3f184979e 2024-04-19 22:11:10 +00:00
Jorrin
9eb9fb494c Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-20 00:11:03 +02:00
Jorrin
bbeb729156 add register and login screens 2024-04-20 00:11:02 +02:00
Adrian Castro
b530284519 chore: clean redundant await expressions 2024-04-19 22:56:54 +02:00
Jorrin
fcfd0d99cc Update pnpm-lock.yaml 2024-04-19 20:42:52 +02:00
Jorrin
75f5256b20 remove ofetch, replace with fetch 2024-04-19 20:41:21 +02:00
Adrian Castro
eea4eab60b feat: api hooks n stuff 2024-04-19 19:19:59 +02:00
37 changed files with 1068 additions and 193 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,190 @@
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

@@ -0,0 +1,68 @@
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

@@ -0,0 +1,132 @@
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,30 +1,19 @@
import { Stack, useLocalSearchParams } from "expo-router"; import { Link, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { H4, Paragraph, Text, View } from "tamagui"; import { H4, Paragraph, Text, View } from "tamagui";
import { getBackendMeta } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout"; import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button"; import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card"; 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() { export default function Page() {
const { url } = useLocalSearchParams(); const { url } = useLocalSearchParams<{ url: string }>();
const meta = useQuery({ const meta = useQuery({
queryKey: ["backendMeta", url], queryKey: ["backendMeta", url],
queryFn: () => getBackendMeta(url as string), queryFn: () => getBackendMeta(url),
}); });
return ( return (
@@ -41,7 +30,7 @@ export default function Page() {
title: "", title: "",
}} }}
/> />
<MWCard bordered> <MWCard bordered padded>
<MWCard.Header padded> <MWCard.Header padded>
<H4 fontWeight="$bold" textAlign="center"> <H4 fontWeight="$bold" textAlign="center">
Do you trust this server? Do you trust this server?
@@ -101,15 +90,23 @@ export default function Page() {
flexDirection="column" flexDirection="column"
gap="$4" gap="$4"
> >
<MWButton type="purple">I trust this server</MWButton> <Link
<MWButton type="secondary">Go back</MWButton> href={{
pathname: "/sync/register",
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold"> }}
Already have an account?{" "} asChild
<Text color="$purple100" fontWeight="$bold"> >
Login here <MWButton type="purple">I trust this server</MWButton>
</Text> </Link>
</Paragraph> <Link
href={{
pathname: "/(tabs)/",
}}
replace
asChild
>
<MWButton type="cancel">Go back</MWButton>
</Link>
</MWCard.Footer> </MWCard.Footer>
</MWCard> </MWCard>
</ScreenLayout> </ScreenLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,204 @@
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

@@ -0,0 +1,170 @@
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<
), ),
); );
interface WatchHistoryItem { export interface WatchHistoryItem {
item: ItemData; item: ItemData;
media: ScrapeMedia; media: ScrapeMedia;
positionMillis: number; positionMillis: number;

View File

@@ -57,17 +57,6 @@ const createThemeConfig = (tokens: Tokens) => ({
loadingIndicator: tokens.purple.c200, 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, switchActiveTrackColor: tokens.purple.c300,
switchInactiveTrackColor: tokens.ash.c500, switchInactiveTrackColor: tokens.ash.c500,
switchThumbColor: tokens.white, switchThumbColor: tokens.white,

View File

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

View File

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

View File

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

View File

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

53
packages/api/src/fetch.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

41
pnpm-lock.yaml generated
View File

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