make settings page fully functional

This commit is contained in:
mrjvs 2023-11-24 21:54:44 +01:00
parent b38e5768e3
commit a9abe14810
12 changed files with 359 additions and 121 deletions

View File

@ -12,6 +12,10 @@ export interface SessionResponse {
userAgent: string;
}
export interface SessionUpdate {
deviceName: string;
}
export async function getSessions(url: string, account: AccountWithToken) {
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
headers: getAuthHeaders(account.token),
@ -19,6 +23,19 @@ export async function getSessions(url: string, account: AccountWithToken) {
});
}
export async function updateSession(
url: string,
account: AccountWithToken,
update: SessionUpdate
) {
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
method: "PATCH",
headers: getAuthHeaders(account.token),
body: update,
baseURL: url,
});
}
export async function removeSession(
url: string,
token: string,

View File

@ -5,7 +5,7 @@ import { AccountWithToken } from "@/stores/auth";
export interface SettingsInput {
applicationLanguage?: string;
applicationTheme?: string;
applicationTheme?: string | null;
defaultSubtitleLanguage?: string;
}

View File

@ -18,6 +18,14 @@ export interface UserResponse {
};
}
export interface UserEdit {
profile?: {
colorA: string;
colorB: string;
icon: string;
};
}
export interface BookmarkResponse {
tmdbId: string;
meta: {
@ -122,6 +130,22 @@ export async function getUser(
);
}
export async function editUser(
url: string,
account: AccountWithToken,
object: UserEdit
): Promise<{ user: UserResponse; session: SessionResponse }> {
return ofetch<{ user: UserResponse; session: SessionResponse }>(
`/users/${account.userId}`,
{
method: "PATCH",
headers: getAuthHeaders(account.token),
body: object,
baseURL: url,
}
);
}
export async function deleteUser(
url: string,
account: AccountWithToken

View File

@ -9,20 +9,31 @@ export interface AvatarProps {
profile: AccountProfile["profile"];
sizeClass?: string;
iconClass?: string;
bottom?: React.ReactNode;
}
export function Avatar(props: AvatarProps) {
return (
<div
className={classNames(
props.sizeClass,
"rounded-full overflow-hidden flex items-center justify-center text-white"
)}
style={{
background: `linear-gradient(to bottom right, ${props.profile.colorA}, ${props.profile.colorB})`,
}}
>
<UserIcon className={props.iconClass} icon={props.profile.icon as any} />
<div className="relative inline-block">
<div
className={classNames(
props.sizeClass,
"rounded-full overflow-hidden flex items-center justify-center text-white"
)}
style={{
background: `linear-gradient(to bottom right, ${props.profile.colorA}, ${props.profile.colorB})`,
}}
>
<UserIcon
className={props.iconClass}
icon={props.profile.icon as any}
/>
</div>
{props.bottom ? (
<div className="absolute bottom-0 left-1/2 transform translate-y-1/2 -translate-x-1/2">
{props.bottom}
</div>
) : null}
</div>
);
}
@ -35,18 +46,12 @@ export function UserAvatar(props: {
const auth = useAuthStore();
if (!auth.account) return null;
return (
<div className="relative inline-block">
<Avatar
profile={auth.account.profile}
sizeClass={props.sizeClass ?? "w-[2rem] h-[2rem]"}
iconClass={props.iconClass}
/>
{props.bottom ? (
<div className="absolute bottom-0 left-1/2 transform translate-y-1/2 -translate-x-1/2">
{props.bottom}
</div>
) : null}
</div>
<Avatar
profile={auth.account.profile}
sizeClass={props.sizeClass ?? "w-[2rem] h-[2rem]"}
iconClass={props.iconClass}
bottom={props.bottom}
/>
);
}

View File

@ -3,7 +3,7 @@ import { useCopyToClipboard, useMountedState } from "react-use";
import { Icon, Icons } from "./Icon";
export function PassphaseDisplay(props: { mnemonic: string }) {
export function PassphraseDisplay(props: { mnemonic: string }) {
const individualWords = props.mnemonic.split(" ");
const [, copy] = useCopyToClipboard();
@ -23,7 +23,7 @@ export function PassphaseDisplay(props: { mnemonic: string }) {
return (
<div className="rounded-lg border border-authentication-border/50 ">
<div className="px-4 py-2 flex justify-between border-b border-authentication-border/50">
<p className="font-bold text-sm text-white">Passphase</p>
<p className="font-bold text-sm text-white">Passphrase</p>
<button
type="button"
className="text-authentication-copyText hover:text-authentication-copyTextHover transition-colors flex gap-2 items-center cursor-pointer"
@ -37,10 +37,12 @@ export function PassphaseDisplay(props: { mnemonic: string }) {
</button>
</div>
<div className="px-4 py-4 grid grid-cols-4 gap-2">
{individualWords.map((word) => (
{individualWords.map((word, i) => (
<div
className="px-4 rounded-md py-2 bg-authentication-wordBackground text-white font-medium text-center"
key={word}
// this doesn't get rerendered nor does it have state so its fine
// eslint-disable-next-line react/no-array-index-key
key={i}
>
{word}
</div>

View File

@ -1,11 +1,18 @@
import isEqual from "lodash.isequal";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { SubtitleStyling } from "@/stores/subtitles";
export function useDerived<T>(
initial: T
): [T, (v: T) => void, () => void, boolean] {
): [T, Dispatch<SetStateAction<T>>, () => void, boolean] {
const [overwrite, setOverwrite] = useState<T | undefined>(undefined);
useEffect(() => {
setOverwrite(undefined);
@ -14,19 +21,39 @@ export function useDerived<T>(
() => !isEqual(overwrite, initial) && overwrite !== undefined,
[overwrite, initial]
);
const setter = useCallback<Dispatch<SetStateAction<T>>>(
(inp) => {
if (!(inp instanceof Function)) setOverwrite(inp);
else setOverwrite((s) => inp(s ?? initial));
},
[initial, setOverwrite]
);
const data = overwrite === undefined ? initial : overwrite;
const reset = useCallback(() => setOverwrite(undefined), [setOverwrite]);
return [data, setOverwrite, reset, changed];
return [data, setter, reset, changed];
}
export function useSettingsState(
theme: string | null,
appLanguage: string,
subtitleStyling: SubtitleStyling,
deviceName?: string
deviceName: string,
proxyUrls: string[] | null,
backendUrl: string | null,
profile:
| {
colorA: string;
colorB: string;
icon: string;
}
| undefined
) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls);
const [backendUrlState, setBackendUrl, resetBackendUrl, backendUrlChanged] =
useDerived(backendUrl);
const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme);
const [
appLanguageState,
@ -42,22 +69,27 @@ export function useSettingsState(
resetDeviceName,
deviceNameChanged,
] = useDerived(deviceName);
const [profileState, setProfileState, resetProfile, profileChanged] =
useDerived(profile);
function reset() {
resetTheme();
resetAppLanguage();
resetSubStyling();
resetProxyUrls();
resetBackendUrl();
resetDeviceName();
resetProfile();
}
const changed = useMemo(
() =>
themeChanged ||
appLanguageChanged ||
subStylingChanged ||
deviceNameChanged,
[themeChanged, appLanguageChanged, subStylingChanged, deviceNameChanged]
);
const changed =
themeChanged ||
appLanguageChanged ||
subStylingChanged ||
deviceNameChanged ||
backendUrlChanged ||
proxyUrlsChanged ||
profileChanged;
return {
reset,
@ -65,18 +97,37 @@ export function useSettingsState(
theme: {
state: themeState,
set: setTheme,
changed: themeChanged,
},
appLanguage: {
state: appLanguageState,
set: setAppLanguage,
changed: appLanguageChanged,
},
subtitleStyling: {
state: subStylingState,
set: setSubStyling,
changed: subStylingChanged,
},
deviceName: {
state: deviceNameState,
set: setDeviceNameState,
changed: deviceNameChanged,
},
proxyUrls: {
state: proxyUrlsState,
set: setProxyUrls,
changed: proxyUrlsChanged,
},
backendUrl: {
state: backendUrlState,
set: setBackendUrl,
changed: backendUrlChanged,
},
profile: {
state: profileState,
set: setProfileState,
changed: profileChanged,
},
};
}

View File

@ -2,12 +2,19 @@ import classNames from "classnames";
import { useCallback, useEffect, useMemo } from "react";
import { useAsyncFn } from "react-use";
import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto";
import { getSessions } from "@/backend/accounts/sessions";
import {
base64ToBuffer,
decryptData,
encryptData,
} from "@/backend/accounts/crypto";
import { getSessions, updateSession } from "@/backend/accounts/sessions";
import { updateSettings } from "@/backend/accounts/settings";
import { editUser } from "@/backend/accounts/user";
import { Button } from "@/components/Button";
import { WideContainer } from "@/components/layout/WideContainer";
import { UserIcons } from "@/components/UserIcon";
import { Heading1 } from "@/components/utils/Text";
import { useAuth } from "@/hooks/auth/useAuth";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useSettingsState } from "@/hooks/useSettingsState";
@ -45,7 +52,17 @@ function SettingsLayout(props: { children: React.ReactNode }) {
);
}
export function AccountSettings(props: { account: AccountWithToken }) {
export function AccountSettings(props: {
account: AccountWithToken;
deviceName: string;
setDeviceName: (s: string) => void;
colorA: string;
setColorA: (s: string) => void;
colorB: string;
setColorB: (s: string) => void;
userIcon: UserIcons;
setUserIcon: (s: UserIcons) => void;
}) {
const url = useBackendUrl();
const { account } = props;
const [sessionsResult, execSessions] = useAsyncFn(() => {
@ -57,7 +74,16 @@ export function AccountSettings(props: { account: AccountWithToken }) {
return (
<>
<AccountEditPart />
<AccountEditPart
deviceName={props.deviceName}
setDeviceName={props.setDeviceName}
colorA={props.colorA}
setColorA={props.setColorA}
colorB={props.colorB}
setColorB={props.setColorB}
userIcon={props.userIcon}
setUserIcon={props.setUserIcon}
/>
<DeviceListPart
error={!!sessionsResult.error}
loading={sessionsResult.loading}
@ -79,7 +105,15 @@ export function SettingsPage() {
const subStyling = useSubtitleStore((s) => s.styling);
const setSubStyling = useSubtitleStore((s) => s.updateStyling);
const proxySet = useAuthStore((s) => s.proxySet);
const setProxySet = useAuthStore((s) => s.setProxySet);
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
const decryptedName = useMemo(() => {
if (!account) return "";
return decryptData(account.deviceName, base64ToBuffer(account.seed));
@ -87,29 +121,71 @@ export function SettingsPage() {
const backendUrl = useBackendUrl();
const { logout } = useAuth();
const user = useAuthStore();
const state = useSettingsState(
activeTheme,
appLanguage,
subStyling,
decryptedName
decryptedName,
proxySet,
backendUrlSetting,
account?.profile
);
const saveChanges = useCallback(async () => {
console.log(state);
if (account) {
await updateSettings(backendUrl, account, {
applicationLanguage: state.appLanguage.state,
applicationTheme: state.theme.state ?? undefined,
});
if (state.appLanguage.changed || state.theme.changed) {
await updateSettings(backendUrl, account, {
applicationLanguage: state.appLanguage.state,
applicationTheme: state.theme.state,
});
}
if (state.deviceName.changed) {
const newDeviceName = await encryptData(
state.deviceName.state,
base64ToBuffer(account.seed)
);
await updateSession(backendUrl, account, {
deviceName: newDeviceName,
});
updateDeviceName(newDeviceName);
}
if (state.profile.changed) {
await editUser(backendUrl, account, {
profile: state.profile.state,
});
}
}
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
setSubStyling(state.subtitleStyling.state);
}, [state, account, backendUrl, setAppLanguage, setTheme, setSubStyling]);
setProxySet(state.proxyUrls.state);
if (state.profile.state) {
updateProfile(state.profile.state);
}
// when backend url gets changed, log the user out first
if (state.backendUrl.changed) {
await logout();
setBackendUrl(state.backendUrl.state);
}
}, [
state,
account,
backendUrl,
setAppLanguage,
setTheme,
setSubStyling,
updateDeviceName,
updateProfile,
setProxySet,
setBackendUrl,
logout,
]);
return (
<SubPageLayout>
<SettingsLayout>
@ -117,8 +193,24 @@ export function SettingsPage() {
<Heading1 border className="!mb-0">
Account
</Heading1>
{user.account ? (
<AccountSettings account={user.account} />
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) => (s ? { ...s, colorA: v } : undefined));
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) => (s ? { ...s, colorB: v } : undefined))
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
}
/>
) : (
<RegisterCalloutPart />
)}
@ -139,7 +231,12 @@ export function SettingsPage() {
/>
</div>
<div id="settings-connection" className="mt-48">
<ConnectionsPart />
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
/>
</div>
</SettingsLayout>
<div

View File

@ -8,7 +8,7 @@ import {
LargeCardButtons,
LargeCardText,
} from "@/components/layout/LargeCard";
import { PassphaseDisplay } from "@/components/PassphraseDisplay";
import { PassphraseDisplay } from "@/components/PassphraseDisplay";
interface PassphraseGeneratePartProps {
onNext?: (mnemonic: string) => void;
@ -23,7 +23,7 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
If you lose this, you&apos;re a silly goose and will be posted on the
wall of shame
</LargeCardText>
<PassphaseDisplay mnemonic={mnemonic} />
<PassphraseDisplay mnemonic={mnemonic} />
<LargeCardButtons>
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>

View File

@ -1,13 +1,23 @@
import { UserAvatar } from "@/components/Avatar";
import { Avatar } from "@/components/Avatar";
import { Button } from "@/components/Button";
import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { useModal } from "@/components/overlays/Modal";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { UserIcons } from "@/components/UserIcon";
import { useAuth } from "@/hooks/auth/useAuth";
import { ProfileEditModal } from "@/pages/parts/settings/ProfileEditModal";
export function AccountEditPart() {
export function AccountEditPart(props: {
deviceName: string;
setDeviceName: (s: string) => void;
colorA: string;
setColorA: (s: string) => void;
colorB: string;
setColorB: (s: string) => void;
userIcon: UserIcons;
setUserIcon: (s: UserIcons) => void;
}) {
const { logout } = useAuth();
const profileEditModal = useModal("profile-edit");
@ -16,10 +26,21 @@ export function AccountEditPart() {
<ProfileEditModal
id={profileEditModal.id}
close={profileEditModal.hide}
colorA={props.colorA}
setColorA={props.setColorA}
colorB={props.colorB}
setColorB={props.setColorB}
userIcon={props.userIcon}
setUserIcon={props.setUserIcon}
/>
<div className="grid lg:grid-cols-[auto,1fr] gap-8">
<div>
<UserAvatar
<Avatar
profile={{
colorA: props.colorA,
colorB: props.colorB,
icon: props.userIcon,
}}
iconClass="text-5xl"
sizeClass="w-32 h-32"
bottom={
@ -36,9 +57,13 @@ export function AccountEditPart() {
</div>
<div>
<div className="space-y-8 max-w-xs">
<AuthInputBox label="Device name" placeholder="Fremen tablet" />
<AuthInputBox
label="Device name"
placeholder="Fremen tablet"
value={props.deviceName}
onChange={(value) => props.setDeviceName(value)}
/>
<div className="flex space-x-3">
<Button theme="purple">Save account</Button>
<Button theme="danger" onClick={logout}>
Log out
</Button>

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { Dispatch, SetStateAction, useCallback } from "react";
import { Button } from "@/components/Button";
import { Toggle } from "@/components/buttons/Toggle";
@ -8,45 +8,38 @@ import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider";
import { Heading1 } from "@/components/utils/Text";
let idNum = 0;
interface ProxyItem {
url: string;
id: number;
interface ProxyEditProps {
proxyUrls: string[] | null;
setProxyUrls: Dispatch<SetStateAction<string[] | null>>;
}
function ProxyEdit() {
const [customWorkers, setCustomWorkers] = useState<ProxyItem[] | null>(null);
interface BackendEditProps {
backendUrl: string | null;
setBackendUrl: Dispatch<SetStateAction<string | null>>;
}
function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) {
const add = useCallback(() => {
idNum += 1;
setCustomWorkers((s) => [
...(s ?? []),
{
id: idNum,
url: "",
},
]);
}, [setCustomWorkers]);
setProxyUrls((s) => [...(s ?? []), ""]);
}, [setProxyUrls]);
const changeItem = useCallback(
(id: number, val: string) => {
setCustomWorkers((s) => [
...(s ?? []).map((v) => {
if (v.id !== id) return v;
v.url = val;
return v;
(index: number, val: string) => {
setProxyUrls((s) => [
...(s ?? []).map((v, i) => {
if (i !== index) return v;
return val;
}),
]);
},
[setCustomWorkers]
[setProxyUrls]
);
const removeItem = useCallback(
(id: number) => {
setCustomWorkers((s) => [...(s ?? []).filter((v) => v.id !== id)]);
(index: number) => {
setProxyUrls((s) => [...(s ?? []).filter((v, i) => i !== index)]);
},
[setCustomWorkers]
[setProxyUrls]
);
return (
@ -61,30 +54,35 @@ function ProxyEdit() {
</div>
<div>
<Toggle
onClick={() => setCustomWorkers((s) => (s === null ? [] : null))}
enabled={customWorkers !== null}
onClick={() => setProxyUrls((s) => (s === null ? [] : null))}
enabled={proxyUrls !== null}
/>
</div>
</div>
{customWorkers !== null ? (
{proxyUrls !== null ? (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3">Worker URLs</p>
<div className="my-6 space-y-2 max-w-md">
{(customWorkers?.length ?? 0) === 0 ? (
{(proxyUrls?.length ?? 0) === 0 ? (
<p>No workers yet, add one below</p>
) : null}
{(customWorkers ?? []).map((v) => (
<div className="grid grid-cols-[1fr,auto] items-center gap-2">
{(proxyUrls ?? []).map((v, i) => (
<div
// not the best but we can live with it
// eslint-disable-next-line react/no-array-index-key
key={i}
className="grid grid-cols-[1fr,auto] items-center gap-2"
>
<AuthInputBox
value={v.url}
onChange={(val) => changeItem(v.id, val)}
value={v}
onChange={(val) => changeItem(i, val)}
placeholder="https://"
/>
<button
type="button"
onClick={() => removeItem(v.id)}
onClick={() => removeItem(i)}
className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
>
<Icon className="text-xl" icon={Icons.X} />
@ -102,9 +100,7 @@ function ProxyEdit() {
);
}
function BackendEdit() {
const [customBackendUrl, setCustomBackendUrl] = useState<string | null>(null);
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
return (
<SettingsCard>
<div className="flex justify-between items-center">
@ -117,32 +113,35 @@ function BackendEdit() {
</div>
<div>
<Toggle
onClick={() => setCustomBackendUrl((s) => (s === null ? "" : null))}
enabled={customBackendUrl !== null}
onClick={() => setBackendUrl((s) => (s === null ? "" : null))}
enabled={backendUrl !== null}
/>
</div>
</div>
{customBackendUrl !== null ? (
{backendUrl !== null ? (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3">Custom server URL</p>
<AuthInputBox
onChange={setCustomBackendUrl}
value={customBackendUrl ?? ""}
/>
<AuthInputBox onChange={setBackendUrl} value={backendUrl ?? ""} />
</>
) : null}
</SettingsCard>
);
}
export function ConnectionsPart() {
export function ConnectionsPart(props: BackendEditProps & ProxyEditProps) {
return (
<div>
<Heading1 border>Connections</Heading1>
<div className="space-y-6">
<ProxyEdit />
<BackendEdit />
<ProxyEdit
proxyUrls={props.proxyUrls}
setProxyUrls={props.setProxyUrls}
/>
<BackendEdit
backendUrl={props.backendUrl}
setBackendUrl={props.setBackendUrl}
/>
</div>
</div>
);

View File

@ -1,5 +1,3 @@
import { useState } from "react";
import { Button } from "@/components/Button";
import { ColorPicker } from "@/components/form/ColorPicker";
import { IconPicker } from "@/components/form/IconPicker";
@ -10,28 +8,34 @@ import { Heading2 } from "@/components/utils/Text";
export interface ProfileEditModalProps {
id: string;
close?: () => void;
colorA: string;
setColorA: (s: string) => void;
colorB: string;
setColorB: (s: string) => void;
userIcon: UserIcons;
setUserIcon: (s: UserIcons) => void;
}
export function ProfileEditModal(props: ProfileEditModalProps) {
const [colorA, setColorA] = useState("#2E65CF");
const [colorB, setColorB] = useState("#2E65CF");
const [userIcon, setUserIcon] = useState<UserIcons>(UserIcons.USER);
return (
<Modal id={props.id}>
<ModalCard>
<Heading2 className="!mt-0">Edit profile picture</Heading2>
<div className="space-y-6">
<ColorPicker label="First color" value={colorA} onInput={setColorA} />
<ColorPicker
label="First color"
value={props.colorA}
onInput={props.setColorA}
/>
<ColorPicker
label="Second color"
value={colorB}
onInput={setColorB}
value={props.colorB}
onInput={props.setColorB}
/>
<IconPicker
label="User icon"
value={userIcon}
onInput={setUserIcon}
value={props.userIcon}
onInput={props.setUserIcon}
/>
</div>
<div className="flex justify-center mt-8">

View File

@ -26,7 +26,9 @@ interface AuthStore {
setAccount(acc: AccountWithToken): void;
updateDeviceName(deviceName: string): void;
updateAccount(acc: Account): void;
setAccountProfile(acc: Account["profile"]): void;
setBackendUrl(url: null | string): void;
setProxySet(urls: null | string[]): void;
}
export const useAuthStore = create(
@ -50,6 +52,18 @@ export const useAuthStore = create(
s.backendUrl = v;
});
},
setProxySet(urls) {
set((s) => {
s.proxySet = urls;
});
},
setAccountProfile(profile) {
set((s) => {
if (s.account) {
s.account.profile = profile;
}
});
},
updateAccount(acc) {
set((s) => {
if (!s.account) return;