mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 14:43:25 +00:00
add header and background design
This commit is contained in:
@@ -28,11 +28,11 @@
|
|||||||
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
|
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
|
||||||
"@react-navigation/native": "^6.1.9",
|
"@react-navigation/native": "^6.1.9",
|
||||||
"@salihgun/react-native-video-processor": "^0.3.1",
|
"@salihgun/react-native-video-processor": "^0.3.1",
|
||||||
"@tamagui/animations-moti": "^1.91.4",
|
"@tamagui/animations-moti": "^1.94.0",
|
||||||
"@tamagui/babel-plugin": "^1.91.4",
|
"@tamagui/babel-plugin": "^1.94.0",
|
||||||
"@tamagui/config": "^1.91.4",
|
"@tamagui/config": "^1.94.0",
|
||||||
"@tamagui/metro-plugin": "^1.91.4",
|
"@tamagui/metro-plugin": "^1.94.0",
|
||||||
"@tamagui/toast": "1.91.4",
|
"@tamagui/toast": "1.94.0",
|
||||||
"@tanstack/react-query": "^5.22.2",
|
"@tanstack/react-query": "^5.22.2",
|
||||||
"burnt": "^0.12.2",
|
"burnt": "^0.12.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
"react-native-svg": "14.1.0",
|
"react-native-svg": "14.1.0",
|
||||||
"react-native-web": "^0.19.10",
|
"react-native-web": "^0.19.10",
|
||||||
"subsrt-ts": "^2.1.2",
|
"subsrt-ts": "^2.1.2",
|
||||||
"tamagui": "^1.91.4",
|
"tamagui": "^1.94.0",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -16,7 +16,7 @@ import Item from "~/components/item/item";
|
|||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { SearchBar } from "~/components/ui/Searchbar";
|
import { SearchBar } from "~/components/ui/Searchbar";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function SearchScreen() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const fadeAnim = useSharedValue(1);
|
const fadeAnim = useSharedValue(1);
|
||||||
@@ -98,42 +98,46 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<ScreenLayout>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
onScrollBeginDrag={handleScrollBegin}
|
onScrollBeginDrag={handleScrollBegin}
|
||||||
onMomentumScrollEnd={handleScrollEnd}
|
onMomentumScrollEnd={handleScrollEnd}
|
||||||
scrollEnabled={searchResultsLoaded ? true : false}
|
scrollEnabled={searchResultsLoaded ? true : false}
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<ScreenLayout>
|
<View>
|
||||||
{searchResultsLoaded && (
|
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
|
||||||
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
|
<View flexDirection="row" flexWrap="wrap">
|
||||||
<View flexDirection="row" flexWrap="wrap">
|
{data?.map((item, index) => (
|
||||||
{data?.map((item, index) => (
|
<View
|
||||||
<View
|
key={index}
|
||||||
key={index}
|
paddingHorizontal={12}
|
||||||
paddingHorizontal={12}
|
paddingBottom={12}
|
||||||
paddingBottom={12}
|
width="50%"
|
||||||
width="50%"
|
>
|
||||||
>
|
<Item data={item} />
|
||||||
<Item data={item} />
|
</View>
|
||||||
</View>
|
))}
|
||||||
))}
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
|
||||||
</ScreenLayout>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{ position: "absolute", left: 0, right: 0, bottom: 0 },
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SearchBar onSearchChange={setQuery} />
|
<SearchBar onSearchChange={setQuery} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</ScreenLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
28
apps/expo/src/components/BrandPill.tsx
Normal file
28
apps/expo/src/components/BrandPill.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Image, Text, View } from "tamagui";
|
||||||
|
|
||||||
|
import Icon from "../../assets/images/icon-transparent.png";
|
||||||
|
|
||||||
|
export function BrandPill() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
flexDirection="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
paddingHorizontal="$2.5"
|
||||||
|
paddingVertical="$2"
|
||||||
|
gap={2}
|
||||||
|
opacity={0.8}
|
||||||
|
backgroundColor="$pillBackground"
|
||||||
|
borderRadius={24}
|
||||||
|
pressStyle={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1.05,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image source={Icon} height={20} width={20} />
|
||||||
|
<Text fontSize="$4" fontWeight="$bold" paddingRight={5}>
|
||||||
|
movie-web
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
41
apps/expo/src/components/layout/Header.tsx
Normal file
41
apps/expo/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Linking } from "react-native";
|
||||||
|
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { Circle, View } from "tamagui";
|
||||||
|
|
||||||
|
import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
|
||||||
|
import { BrandPill } from "../BrandPill";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<View alignItems="center" gap="$3" flexDirection="row">
|
||||||
|
<BrandPill />
|
||||||
|
|
||||||
|
<Circle
|
||||||
|
backgroundColor="$pillBackground"
|
||||||
|
size="$2.5"
|
||||||
|
pressStyle={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1.05,
|
||||||
|
}}
|
||||||
|
onPress={async () => {
|
||||||
|
await Linking.openURL(DISCORD_LINK);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="discord" size={20} color="white" />
|
||||||
|
</Circle>
|
||||||
|
<Circle
|
||||||
|
backgroundColor="$pillBackground"
|
||||||
|
size="$2.5"
|
||||||
|
pressStyle={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1.05,
|
||||||
|
}}
|
||||||
|
onPress={async () => {
|
||||||
|
await Linking.openURL(GITHUB_LINK);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesome6 name="github" size={20} color="white" />
|
||||||
|
</Circle>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,24 +1,34 @@
|
|||||||
import { Text, View } from "tamagui";
|
import { View } from "tamagui";
|
||||||
|
import { LinearGradient } from "tamagui/linear-gradient";
|
||||||
|
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: React.ReactNode | string;
|
|
||||||
subtitle?: string;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScreenLayout({ title, subtitle, children }: Props) {
|
export default function ScreenLayout({ children }: Props) {
|
||||||
return (
|
return (
|
||||||
<View flex={1} padding={44} backgroundColor="$screenBackground">
|
<LinearGradient
|
||||||
{typeof title === "string" && (
|
flex={1}
|
||||||
<Text fontWeight="bold" fontSize={24}>
|
paddingVertical="$4"
|
||||||
{title}
|
paddingHorizontal="$7"
|
||||||
</Text>
|
colors={[
|
||||||
)}
|
"$shade900",
|
||||||
{typeof title !== "string" && title}
|
"$purple900",
|
||||||
<Text fontSize={16} fontWeight="bold" marginTop={1}>
|
"$purple800",
|
||||||
{subtitle}
|
"$shade700",
|
||||||
</Text>
|
"$shade900",
|
||||||
<View paddingVertical={12}>{children}</View>
|
]}
|
||||||
</View>
|
locations={[0.02, 0.15, 0.2, 0.4, 0.8]}
|
||||||
|
start={[0, 0]}
|
||||||
|
end={[1, 1]}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<View paddingVertical="$4" flexGrow={1}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Image, Text, View } from "tamagui";
|
import { Text, View } from "tamagui";
|
||||||
|
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
import Icon from "../../../assets/images/icon-transparent.png";
|
import { BrandPill } from "../BrandPill";
|
||||||
import { BackButton } from "./BackButton";
|
import { BackButton } from "./BackButton";
|
||||||
import { Controls } from "./Controls";
|
import { Controls } from "./Controls";
|
||||||
|
|
||||||
@@ -39,21 +39,8 @@ export const Header = () => {
|
|||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<View
|
<View alignItems="center" justifyContent="center" width={130}>
|
||||||
height="$3.5"
|
<BrandPill />
|
||||||
width="$11"
|
|
||||||
flexDirection="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
gap={2}
|
|
||||||
paddingHorizontal="$4"
|
|
||||||
paddingVertical="$1"
|
|
||||||
opacity={0.8}
|
|
||||||
backgroundColor="$pillBackground"
|
|
||||||
borderRadius={24}
|
|
||||||
>
|
|
||||||
<Image source={Icon} height={24} width={24} />
|
|
||||||
<Text fontWeight="bold">movie-web</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@@ -28,7 +28,7 @@ export const QualitySelector = () => {
|
|||||||
(key) => qualities[key as keyof typeof qualities]!.url === videoSrc.uri,
|
(key) => qualities[key as keyof typeof qualities]!.url === videoSrc.uri,
|
||||||
);
|
);
|
||||||
|
|
||||||
qualityMap = Object.keys(qualities).map((key: string) => ({
|
qualityMap = Object.keys(qualities).map((key) => ({
|
||||||
quality: key,
|
quality: key,
|
||||||
url: qualities[key as keyof typeof qualities]!.url,
|
url: qualities[key as keyof typeof qualities]!.url,
|
||||||
}));
|
}));
|
||||||
|
@@ -24,6 +24,7 @@ import { useScrape } from "~/hooks/player/useSourceScrape";
|
|||||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
|
import { BackButton } from "./BackButton";
|
||||||
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
|
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
|
||||||
|
|
||||||
interface ScraperProcessProps {
|
interface ScraperProcessProps {
|
||||||
@@ -169,6 +170,9 @@ export const ScraperProcess = ({
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
backgroundColor="$screenBackground"
|
backgroundColor="$screenBackground"
|
||||||
>
|
>
|
||||||
|
<View position="absolute" top={40} left={40}>
|
||||||
|
<BackButton />
|
||||||
|
</View>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
@@ -17,7 +17,6 @@ export const MWInput = styled(Input, {
|
|||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
focusStyle: {
|
focusStyle: {
|
||||||
borderColor: "$colorTransparent",
|
borderColor: "$colorTransparent",
|
||||||
backgroundColor: "$searchFocused",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -51,7 +51,6 @@ export function SearchBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
marginBottom={12}
|
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
@@ -67,8 +66,7 @@ export function SearchBar({
|
|||||||
onChangeText={handleChange}
|
onChangeText={handleChange}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="What are you looking for?"
|
placeholder="What are you looking for?"
|
||||||
width="80%"
|
width="75%"
|
||||||
borderColor={isFocused ? theme.colorTransparent : theme.inputBorder}
|
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
isFocused ? theme.searchFocused : theme.searchBackground
|
isFocused ? theme.searchFocused : theme.searchBackground
|
||||||
}
|
}
|
||||||
|
2
apps/expo/src/constants/core.ts
Normal file
2
apps/expo/src/constants/core.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const DISCORD_LINK = "https://movie-web.github.io/links/discord";
|
||||||
|
export const GITHUB_LINK = "https://github.com/movie-web";
|
@@ -21,7 +21,9 @@ export const useThemeStore = create(
|
|||||||
updateTheme(newTheme);
|
updateTheme(newTheme);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.theme = newTheme;
|
state.theme = newTheme;
|
||||||
void setAlternateAppIcon(newTheme);
|
setAlternateAppIcon(newTheme).catch(() => {
|
||||||
|
console.log("Failed to set alternate app icon");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@@ -83,22 +83,97 @@ const createThemeConfig = (tokens: Tokens) => ({
|
|||||||
inputPlaceholderText: tokens.shade.c300,
|
inputPlaceholderText: tokens.shade.c300,
|
||||||
inputText: tokens.white,
|
inputText: tokens.white,
|
||||||
inputIconColor: tokens.shade.c50,
|
inputIconColor: tokens.shade.c50,
|
||||||
|
|
||||||
|
red100: tokens.semantic.red.c100,
|
||||||
|
red200: tokens.semantic.red.c200,
|
||||||
|
red300: tokens.semantic.red.c300,
|
||||||
|
red400: tokens.semantic.red.c400,
|
||||||
|
|
||||||
|
green100: tokens.semantic.green.c100,
|
||||||
|
green200: tokens.semantic.green.c200,
|
||||||
|
green300: tokens.semantic.green.c300,
|
||||||
|
green400: tokens.semantic.green.c400,
|
||||||
|
|
||||||
|
silver100: tokens.semantic.silver.c100,
|
||||||
|
silver200: tokens.semantic.silver.c200,
|
||||||
|
silver300: tokens.semantic.silver.c300,
|
||||||
|
silver400: tokens.semantic.silver.c400,
|
||||||
|
|
||||||
|
yellow100: tokens.semantic.yellow.c100,
|
||||||
|
yellow200: tokens.semantic.yellow.c200,
|
||||||
|
yellow300: tokens.semantic.yellow.c300,
|
||||||
|
yellow400: tokens.semantic.yellow.c400,
|
||||||
|
|
||||||
|
rose100: tokens.semantic.rose.c100,
|
||||||
|
rose200: tokens.semantic.rose.c200,
|
||||||
|
rose300: tokens.semantic.rose.c300,
|
||||||
|
rose400: tokens.semantic.rose.c400,
|
||||||
|
|
||||||
|
blue50: tokens.blue.c50,
|
||||||
|
blue100: tokens.blue.c100,
|
||||||
|
blue200: tokens.blue.c200,
|
||||||
|
blue300: tokens.blue.c300,
|
||||||
|
blue400: tokens.blue.c400,
|
||||||
|
blue500: tokens.blue.c500,
|
||||||
|
blue600: tokens.blue.c600,
|
||||||
|
blue700: tokens.blue.c700,
|
||||||
|
blue800: tokens.blue.c800,
|
||||||
|
blue900: tokens.blue.c900,
|
||||||
|
|
||||||
|
purple50: tokens.purple.c50,
|
||||||
|
purple100: tokens.purple.c100,
|
||||||
|
purple200: tokens.purple.c200,
|
||||||
|
purple300: tokens.purple.c300,
|
||||||
|
purple400: tokens.purple.c400,
|
||||||
|
purple500: tokens.purple.c500,
|
||||||
|
purple600: tokens.purple.c600,
|
||||||
|
purple700: tokens.purple.c700,
|
||||||
|
purple800: tokens.purple.c800,
|
||||||
|
purple900: tokens.purple.c900,
|
||||||
|
|
||||||
|
ash50: tokens.ash.c50,
|
||||||
|
ash100: tokens.ash.c100,
|
||||||
|
ash200: tokens.ash.c200,
|
||||||
|
ash300: tokens.ash.c300,
|
||||||
|
ash400: tokens.ash.c400,
|
||||||
|
ash500: tokens.ash.c500,
|
||||||
|
ash600: tokens.ash.c600,
|
||||||
|
ash700: tokens.ash.c700,
|
||||||
|
ash800: tokens.ash.c800,
|
||||||
|
ash900: tokens.ash.c900,
|
||||||
|
|
||||||
|
shade50: tokens.shade.c50,
|
||||||
|
shade100: tokens.shade.c100,
|
||||||
|
shade200: tokens.shade.c200,
|
||||||
|
shade300: tokens.shade.c300,
|
||||||
|
shade400: tokens.shade.c400,
|
||||||
|
shade500: tokens.shade.c500,
|
||||||
|
shade600: tokens.shade.c600,
|
||||||
|
shade700: tokens.shade.c700,
|
||||||
|
shade800: tokens.shade.c800,
|
||||||
|
shade900: tokens.shade.c900,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openSansFace = {
|
const openSansFace = {
|
||||||
normal: { normal: "OpenSans-Regular", italic: "OpenSans-Italic" },
|
normal: { normal: "OpenSansRegular", italic: "OpenSansItalic" },
|
||||||
bold: { normal: "OpenSans-Bold", italic: "OpenSans-BoldItalic" },
|
bold: { normal: "OpenSansBold", italic: "OpenSansBoldItalic" },
|
||||||
300: { normal: "OpenSans-Light", italic: "OpenSans-LightItalic" },
|
300: { normal: "OpenSansLight", italic: "OpenSansLightItalic" },
|
||||||
500: { normal: "OpenSans-Regular", italic: "OpenSans-Italic" },
|
500: { normal: "OpenSansRegular", italic: "OpenSansItalic" },
|
||||||
600: { normal: "OpenSans-SemiBold", italic: "OpenSans-SemiBoldItalic" },
|
600: { normal: "OpenSansSemiBold", italic: "OpenSansSemiBoldItalic" },
|
||||||
700: { normal: "OpenSans-Bold", italic: "OpenSans-BoldItalic" },
|
700: { normal: "OpenSansBold", italic: "OpenSansBoldItalic" },
|
||||||
800: { normal: "OpenSans-ExtraBold", italic: "OpenSans-ExtraBoldItalic" },
|
800: { normal: "OpenSansExtraBold", italic: "OpenSansExtraBoldItalic" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingFont = createFont({
|
const headingFont = createFont({
|
||||||
size: config.fonts.heading.size,
|
size: config.fonts.heading.size,
|
||||||
lineHeight: config.fonts.heading.lineHeight,
|
lineHeight: config.fonts.heading.lineHeight,
|
||||||
weight: config.fonts.heading.weight,
|
weight: {
|
||||||
|
light: 300,
|
||||||
|
normal: 500,
|
||||||
|
semibold: 600,
|
||||||
|
bold: 700,
|
||||||
|
extrabold: 800,
|
||||||
|
},
|
||||||
letterSpacing: config.fonts.heading.letterSpacing,
|
letterSpacing: config.fonts.heading.letterSpacing,
|
||||||
face: openSansFace,
|
face: openSansFace,
|
||||||
});
|
});
|
||||||
@@ -106,7 +181,13 @@ const headingFont = createFont({
|
|||||||
const bodyFont = createFont({
|
const bodyFont = createFont({
|
||||||
size: config.fonts.body.size,
|
size: config.fonts.body.size,
|
||||||
lineHeight: config.fonts.body.lineHeight,
|
lineHeight: config.fonts.body.lineHeight,
|
||||||
weight: config.fonts.body.weight,
|
weight: {
|
||||||
|
light: 300,
|
||||||
|
normal: 500,
|
||||||
|
semibold: 600,
|
||||||
|
bold: 700,
|
||||||
|
extrabold: 800,
|
||||||
|
},
|
||||||
letterSpacing: config.fonts.body.letterSpacing,
|
letterSpacing: config.fonts.body.letterSpacing,
|
||||||
face: openSansFace,
|
face: openSansFace,
|
||||||
});
|
});
|
||||||
|
1658
pnpm-lock.yaml
generated
1658
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user