diff --git a/apps/expo/src/app/(tabs)/movie-web.tsx b/apps/expo/src/app/(tabs)/movie-web.tsx
index 1d20c47..a73a7c1 100644
--- a/apps/expo/src/app/(tabs)/movie-web.tsx
+++ b/apps/expo/src/app/(tabs)/movie-web.tsx
@@ -1,60 +1,10 @@
-import { Link } from "expo-router";
-import { H3, H5, Paragraph, View } 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";
+import { AccountInformation } from "~/components/account/AccountInformation";
+import { AccountGetStarted } from "~/components/account/GetStarted";
import { useAuthStore } from "~/stores/settings";
export default function MovieWebScreen() {
- const { backendUrl, setBackendUrl } = useAuthStore();
+ const account = useAuthStore((state) => state.account);
- return (
-
-
-
-
- Sync to the cloud
-
-
- Share your watch progress between devices and keep them synced.
-
-
- First choose the backend you want to use. If you do not know what
- this does, use the default and click on 'Get started'.
-
-
-
-
-
-
-
-
-
-
- Get started
-
-
-
-
-
- );
+ if (account) return ;
+ return ;
}
diff --git a/apps/expo/src/app/sync/login.tsx b/apps/expo/src/app/sync/login.tsx
index 603faab..4381803 100644
--- a/apps/expo/src/app/sync/login.tsx
+++ b/apps/expo/src/app/sync/login.tsx
@@ -1,12 +1,40 @@
-import { Stack } from "expo-router";
+import { useState } from "react";
+import { Link, Stack, useRouter } from "expo-router";
+import { useMutation } from "@tanstack/react-query";
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";
+import { useAuth } from "~/hooks/useAuth";
+import { useAuthStore } from "~/stores/settings";
export default function Page() {
+ const backendUrl = useAuthStore((state) => state.backendUrl);
+ const router = useRouter();
+ const { login } = useAuth();
+
+ const [passphrase, setPassphrase] = useState("");
+ const [deviceName, setDeviceName] = useState("");
+
+ const mutation = useMutation({
+ mutationKey: ["login", backendUrl, passphrase, deviceName],
+ mutationFn: () =>
+ login({
+ mnemonic: passphrase,
+ userData: {
+ device: deviceName,
+ },
+ }),
+ onSuccess: (data) => {
+ if (data) {
+ return router.push("/(tabs)/movie-web");
+ }
+ return null;
+ },
+ });
+
return (
@@ -54,6 +84,8 @@ export default function Page() {
type="authentication"
placeholder="Personal phone"
autoCorrect={false}
+ value={deviceName}
+ onChangeText={setDeviceName}
/>
@@ -64,13 +96,22 @@ export default function Page() {
flexDirection="column"
gap="$4"
>
- Login
+ mutation.mutate()}>
+ Login
+
+ {mutation.isError && (
+
+ {mutation.error.message}
+
+ )}
Don't have an account yet?{"\n"}
-
- Create an account.
-
+
+
+ Create an account.
+
+
diff --git a/apps/expo/src/app/sync/register/account.tsx b/apps/expo/src/app/sync/register/account.tsx
index 162a69b..b4dd052 100644
--- a/apps/expo/src/app/sync/register/account.tsx
+++ b/apps/expo/src/app/sync/register/account.tsx
@@ -1,115 +1,43 @@
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 { Stack, useRouter } from "expo-router";
+import { H4, Label, Paragraph, View, YStack } from "tamagui";
+import { Avatar } from "~/components/account/Avatar";
+import { ColorPicker, colors } from "~/components/account/ColorPicker";
+import {
+ expoIcons,
+ expoIconsToDbIcons,
+ UserIconPicker,
+} from "~/components/account/UserIconPicker";
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 (
-
- {colors.map((color) => {
- return (
- props.onInput(color)}
- flexGrow={1}
- height="$4"
- borderRadius="$4"
- justifyContent="center"
- alignItems="center"
- backgroundColor={color}
- key={color}
- >
- {props.value === color ? (
-
- ) : null}
-
- );
- })}
-
- );
-}
-
-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 (
-
- {icons.map((icon) => {
- return (
- props.onInput(icon)}
- >
-
-
- );
- })}
-
- );
-}
-
-interface AvatarProps {
- colorA: string;
- colorB: string;
- icon: (typeof icons)[number];
-}
-
-export function Avatar(props: AvatarProps) {
- return (
-
-
-
-
-
- );
-}
-
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]);
+ const router = useRouter();
+
+ const [deviceName, setDeviceName] = useState("");
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]);
+ const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]);
+ const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]);
+
+ const handleNext = () => {
+ if (!deviceName) {
+ setErrorMessage("Please enter a device name");
+ return;
+ }
+ return router.push({
+ pathname: "/sync/register/confirm",
+ params: {
+ deviceName,
+ colorA,
+ colorB,
+ icon: expoIconsToDbIcons[icon],
+ },
+ });
+ };
return (
-
+
@@ -152,18 +80,19 @@ export default function Page() {
- setColor(color)} />
+ setColorA(color)} />
- setColor2(color)} />
+ setColorB(color)} />
@@ -172,17 +101,14 @@ export default function Page() {
-
-
- Next
-
-
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ Next
+
diff --git a/apps/expo/src/app/sync/register/confirm.tsx b/apps/expo/src/app/sync/register/confirm.tsx
index 517de54..5e38b26 100644
--- a/apps/expo/src/app/sync/register/confirm.tsx
+++ b/apps/expo/src/app/sync/register/confirm.tsx
@@ -1,12 +1,45 @@
-import { Link, Stack } from "expo-router";
+import { useState } from "react";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useMutation } from "@tanstack/react-query";
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";
+import { useAuth } from "~/hooks/useAuth";
export default function Page() {
+ const router = useRouter();
+ const { deviceName, colorA, colorB, icon } = useLocalSearchParams<{
+ deviceName: string;
+ colorA: string;
+ colorB: string;
+ icon: string;
+ }>();
+ const { register } = useAuth();
+
+ const [passphrase, setPassphrase] = useState("");
+
+ const mutation = useMutation({
+ mutationKey: ["register", deviceName, colorA, colorB, icon],
+ mutationFn: () =>
+ register({
+ // TODO: "Add recaptchaToken",
+ mnemonic: passphrase,
+ userData: {
+ device: deviceName,
+ profile: { colorA, colorB, icon },
+ },
+ }),
+ onSuccess: (data) => {
+ if (data) {
+ return router.push("/(tabs)/movie-web");
+ }
+ return null;
+ },
+ });
+
return (
-
- Create account
-
+ {mutation.isError && (
+
+ {mutation.error.message}
+
+ )}
+
+ mutation.mutate()}>
+ Create account
+
diff --git a/apps/expo/src/app/sync/register/index.tsx b/apps/expo/src/app/sync/register/index.tsx
index 65ca629..d1ceb0a 100644
--- a/apps/expo/src/app/sync/register/index.tsx
+++ b/apps/expo/src/app/sync/register/index.tsx
@@ -28,7 +28,7 @@ function PassphraseWord({ word }: { word: string }) {
export default function Page() {
const theme = useTheme();
- const words = genMnemonic().split(" ");
+ const words = genMnemonic();
return (
{
- await Clipboard.setStringAsync(words.join(""));
+ await Clipboard.setStringAsync(words);
}}
>
@@ -103,7 +103,7 @@ export default function Page() {
justifyContent="center"
padding="$3"
>
- {words.map((word, index) => (
+ {words.split(" ").map((word, index) => (
))}
@@ -122,7 +122,13 @@ export default function Page() {
Already have an account?{"\n"}
- Login here
+
+ Login here
+
diff --git a/apps/expo/src/app/sync/trust/[url].tsx b/apps/expo/src/app/sync/trust/[backendUrl].tsx
similarity index 83%
rename from apps/expo/src/app/sync/trust/[url].tsx
rename to apps/expo/src/app/sync/trust/[backendUrl].tsx
index 828afb2..42eeadf 100644
--- a/apps/expo/src/app/sync/trust/[url].tsx
+++ b/apps/expo/src/app/sync/trust/[backendUrl].tsx
@@ -7,13 +7,16 @@ import { getBackendMeta } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
+import { useAuthStore } from "~/stores/settings";
export default function Page() {
- const { url } = useLocalSearchParams<{ url: string }>();
+ const { backendUrl } = useLocalSearchParams<{ backendUrl: string }>();
+
+ const setBackendUrl = useAuthStore((state) => state.setBackendUrl);
const meta = useQuery({
- queryKey: ["backendMeta", url],
- queryFn: () => getBackendMeta(url as unknown as string),
+ queryKey: ["backendMeta", backendUrl],
+ queryFn: () => getBackendMeta(backendUrl as unknown as string),
});
return (
@@ -52,7 +55,7 @@ export default function Page() {
color="white"
textDecorationLine="underline"
>
- {url}
+ {backendUrl}
. Please confirm you trust it before making an account.
>
@@ -95,8 +98,13 @@ export default function Page() {
pathname: "/sync/register",
}}
asChild
+ onPress={() => {
+ setBackendUrl(backendUrl);
+ }}
>
- I trust this server
+
+ I trust this server
+
state.account);
+ const backendUrl = useAuthStore((state) => state.backendUrl);
+ const queryClient = useQueryClient();
+
+ const { decryptedName, logout } = useAuth();
+
+ const logoutMutation = useMutation({
+ mutationKey: ["logout"],
+ mutationFn: logout,
+ });
+
+ const removeSessionMutation = useMutation({
+ mutationKey: ["removeSession"],
+ mutationFn: (sessionId: string) =>
+ removeSession(backendUrl, account!.token, sessionId),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: ["sessions", backendUrl, account],
+ });
+ },
+ });
+
+ const sessions = useQuery({
+ queryKey: ["sessions", backendUrl, account],
+ queryFn: () => getSessions(backendUrl, account!),
+ enabled: !!account,
+ });
+
+ const deviceListSorted = useMemo(() => {
+ let list =
+ sessions.data?.map((session) => {
+ const decryptedName = decryptData(
+ session.device,
+ base64ToBuffer(account!.seed),
+ );
+ return {
+ current: session.id === account!.sessionId,
+ id: session.id,
+ name: decryptedName,
+ };
+ }) ?? [];
+ list = list.sort((a, b) => {
+ if (a.current) return -1;
+ if (b.current) return 1;
+ return a.name.localeCompare(b.name);
+ });
+ return list;
+ }, [sessions.data, account]);
+
+ if (!account) return null;
+
+ return (
+
+
+
+
+ Account
+
+
+
+
+
+
+
+ Device name
+
+
+ logoutMutation.mutate()}
+ >
+ Logout
+
+
+
+
+
+
+
+
+ Devices
+
+
+ {sessions.isLoading && }
+ {deviceListSorted.map((device) => (
+
+
+
+
+ Device name
+
+ {device.name}
+
+ {!device.current && (
+ removeSessionMutation.mutate(device.id)}
+ >
+ Remove
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/expo/src/components/account/Avatar.tsx b/apps/expo/src/components/account/Avatar.tsx
new file mode 100644
index 0000000..248035d
--- /dev/null
+++ b/apps/expo/src/components/account/Avatar.tsx
@@ -0,0 +1,38 @@
+import type { CircleProps } from "tamagui";
+import { FontAwesome6 } from "@expo/vector-icons";
+import { Circle } from "tamagui";
+import { LinearGradient } from "tamagui/linear-gradient";
+
+import type { expoIcons } from "./UserIconPicker";
+
+export interface AvatarProps {
+ colorA: string;
+ colorB: string;
+ icon: (typeof expoIcons)[number];
+}
+
+export function Avatar(props: AvatarProps & CircleProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/expo/src/components/account/ColorPicker.tsx b/apps/expo/src/components/account/ColorPicker.tsx
new file mode 100644
index 0000000..d21aefa
--- /dev/null
+++ b/apps/expo/src/components/account/ColorPicker.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Ionicons } from "@expo/vector-icons";
+import { View, XStack } from "tamagui";
+
+export const colors = [
+ "#0A54FF",
+ "#CF2E68",
+ "#F9DD7F",
+ "#7652DD",
+ "#2ECFA8",
+] as const;
+
+export function ColorPicker(props: {
+ value: (typeof colors)[number];
+ onInput: (v: (typeof colors)[number]) => void;
+}) {
+ return (
+
+ {colors.map((color) => {
+ return (
+ props.onInput(color)}
+ flexGrow={1}
+ height="$4"
+ borderRadius="$4"
+ justifyContent="center"
+ alignItems="center"
+ backgroundColor={color}
+ key={color}
+ >
+ {props.value === color ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/expo/src/components/account/GetStarted.tsx b/apps/expo/src/components/account/GetStarted.tsx
new file mode 100644
index 0000000..eacacaf
--- /dev/null
+++ b/apps/expo/src/components/account/GetStarted.tsx
@@ -0,0 +1,60 @@
+import { Link } from "expo-router";
+import { H3, H5, Paragraph, View } from "tamagui";
+
+import { useAuthStore } from "~/stores/settings";
+import ScreenLayout from "../layout/ScreenLayout";
+import { MWButton } from "../ui/Button";
+import { MWCard } from "../ui/Card";
+import { MWInput } from "../ui/Input";
+
+export function AccountGetStarted() {
+ const { backendUrl, setBackendUrl } = useAuthStore();
+
+ return (
+
+
+
+
+ Sync to the cloud
+
+
+ Share your watch progress between devices and keep them synced.
+
+
+ First choose the backend you want to use. If you do not know what
+ this does, use the default and click on 'Get started'.
+
+
+
+
+
+
+
+
+
+
+ Get started
+
+
+
+
+
+ );
+}
diff --git a/apps/expo/src/components/account/UserIconPicker.tsx b/apps/expo/src/components/account/UserIconPicker.tsx
new file mode 100644
index 0000000..644cd71
--- /dev/null
+++ b/apps/expo/src/components/account/UserIconPicker.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { FontAwesome6 } from "@expo/vector-icons";
+import { View, XStack } from "tamagui";
+
+export const expoIcons = [
+ "user-group",
+ "couch",
+ "mobile-screen",
+ "ticket",
+ "handcuffs",
+] as const;
+
+export const expoIconsToDbIcons: Record<(typeof expoIcons)[number], string> = {
+ "user-group": "userGroup",
+ couch: "couch",
+ "mobile-screen": "mobile",
+ ticket: "ticket",
+ handcuffs: "handcuffs",
+};
+
+export const getExpoIconFromDbIcon = (icon: string) => {
+ return Object.keys(expoIconsToDbIcons).find(
+ (key) => expoIconsToDbIcons[key as (typeof expoIcons)[number]] === icon,
+ ) as (typeof expoIcons)[number];
+};
+
+export function UserIconPicker(props: {
+ value: (typeof expoIcons)[number];
+ onInput: (v: (typeof expoIcons)[number]) => void;
+}) {
+ return (
+
+ {expoIcons.map((icon) => {
+ return (
+ props.onInput(icon)}
+ >
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/expo/src/components/ui/Button.tsx b/apps/expo/src/components/ui/Button.tsx
index c19f48c..9aa2cbf 100644
--- a/apps/expo/src/components/ui/Button.tsx
+++ b/apps/expo/src/components/ui/Button.tsx
@@ -10,6 +10,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$silver100",
},
+ disabledStyle: {
+ backgroundColor: "$ash500",
+ color: "$ash200",
+ pointerEvents: "none",
+ },
},
secondary: {
backgroundColor: "$ash700",
@@ -18,6 +23,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$ash500",
},
+ disabledStyle: {
+ backgroundColor: "$ash900",
+ color: "$ash200",
+ pointerEvents: "none",
+ },
},
purple: {
backgroundColor: "$purple500",
@@ -26,6 +36,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$purple400",
},
+ disabledStyle: {
+ backgroundColor: "$purple700",
+ color: "$ash200",
+ pointerEvents: "none",
+ },
},
cancel: {
backgroundColor: "$ash500",
@@ -34,6 +49,24 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$ash300",
},
+ disabledStyle: {
+ backgroundColor: "$ash700",
+ color: "$ash200",
+ pointerEvents: "none",
+ },
+ },
+ danger: {
+ backgroundColor: "$rose300",
+ color: "white",
+ fontWeight: "bold",
+ pressStyle: {
+ backgroundColor: "$rose200",
+ },
+ disabledStyle: {
+ backgroundColor: "$rose500",
+ color: "$ash200",
+ pointerEvents: "none",
+ },
},
},
} as const,
diff --git a/apps/expo/src/hooks/useAuth.ts b/apps/expo/src/hooks/useAuth.ts
index 65e9cbe..099ad5a 100644
--- a/apps/expo/src/hooks/useAuth.ts
+++ b/apps/expo/src/hooks/useAuth.ts
@@ -1,4 +1,4 @@
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import type {
AccountWithToken,
@@ -8,9 +8,11 @@ import type {
UserResponse,
} from "@movie-web/api";
import {
+ base64ToBuffer,
bookmarkMediaToInput,
bytesToBase64,
bytesToBase64Url,
+ decryptData,
encryptData,
getBookmarks,
getLoginChallengeToken,
@@ -192,9 +194,18 @@ export function useAuth() {
[backendUrl, syncData, logout],
);
+ const decryptedName = useMemo(() => {
+ if (!currentAccount) return "";
+ return decryptData(
+ currentAccount.deviceName,
+ base64ToBuffer(currentAccount.seed),
+ );
+ }, [currentAccount]);
+
return {
loggedIn,
profile,
+ decryptedName,
login,
logout,
register,
diff --git a/packages/api/src/fetch.ts b/packages/api/src/fetch.ts
index c0260b4..1e7805c 100644
--- a/packages/api/src/fetch.ts
+++ b/packages/api/src/fetch.ts
@@ -38,7 +38,10 @@ export async function f(url: string, ops?: FetcherOptions): Promise {
const fullUrl = makeFullUrl(url, ops);
const response = await fetch(fullUrl, {
method: ops?.method ?? "GET",
- headers: ops?.headers,
+ headers: {
+ "Content-Type": "application/json",
+ ...ops?.headers,
+ },
body: ops?.body ? JSON.stringify(ops.body) : undefined,
});