Initial commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { Image } from "tamagui";
|
||||
|
||||
type AppLogoProps = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export const AppIcon = (props: AppLogoProps) => {
|
||||
const { width = 80, height = 80 } = props;
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={require("@/assets/images/logo.png")}
|
||||
width={height}
|
||||
height={width}
|
||||
objectFit="contain"
|
||||
marginBottom="$2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import { Caption } from "@/ui/components/typography";
|
||||
|
||||
export const LoadingView = () => (
|
||||
<View flex={1} padding="$4" backgroundColor="$background" alignItems="center" justifyContent="center" gap="$4">
|
||||
<ActivityIndicator />
|
||||
<Caption>Chargement...</Caption>
|
||||
</View>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
import { Caption } from "@/ui/components/typography";
|
||||
|
||||
type ArticleCategoryPillProps = {
|
||||
category: string;
|
||||
};
|
||||
|
||||
export const ArticleCategoryPill = (props: ArticleCategoryPillProps) => {
|
||||
const { category } = props;
|
||||
|
||||
return <Caption>{category}</Caption>;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { GetProps, Image, styled } from "tamagui";
|
||||
|
||||
const StyledImage = styled(Image, {
|
||||
borderRadius: "$4",
|
||||
backgroundColor: "$gray3",
|
||||
objectFit: "cover",
|
||||
});
|
||||
|
||||
type ArticleCoverImageProps = GetProps<typeof StyledImage> & {
|
||||
uri: string;
|
||||
width: string | number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const ArticleCoverImage = (props: ArticleCoverImageProps) => {
|
||||
const { width, height, uri, ...rest } = props;
|
||||
|
||||
return <StyledImage source={{ uri, cache: "force-cache" }} width={width} height={height} {...rest} />;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import { ActivityIndicator, Dimensions, FlatList, FlatListProps } from "react-native";
|
||||
import { View, XStack, YStack } from "tamagui";
|
||||
|
||||
import { ArticleOverview } from "@/api/schema/feed-management/article";
|
||||
import { ArticleMagazineCard } from "@/ui/components/content/article/ArticleMagazineCard";
|
||||
import { ArticleOverviewCard } from "@/ui/components/content/article/ArticleOverviewCard";
|
||||
import { ArticleTextOnlyCard } from "@/ui/components/content/article/ArticleTextOnlyCard";
|
||||
import { Text } from "@/ui/components/typography";
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
|
||||
const HorizontalSeparator = () => <XStack width="$1" />;
|
||||
const VerticalSeparator = () => <YStack height="$1" />;
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<>
|
||||
<YStack height="$1" />
|
||||
<ActivityIndicator />
|
||||
<YStack height="$1" />
|
||||
</>
|
||||
);
|
||||
|
||||
export type ArticleListDisplayMode = "card" | "magazine" | "text-only";
|
||||
|
||||
type ArticleListProps = Omit<FlatListProps<ArticleOverview>, "renderItem"> & {
|
||||
data: ArticleOverview[];
|
||||
horizontal?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
displayMode?: ArticleListDisplayMode;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
};
|
||||
|
||||
type ArticleListComponent = React.FC<ArticleListProps> & {
|
||||
HorizontalSeparator: typeof HorizontalSeparator;
|
||||
VerticalSeparator: typeof VerticalSeparator;
|
||||
LoadingIndicator: typeof LoadingIndicator;
|
||||
};
|
||||
|
||||
const keyExtractor = (item: ArticleOverview) => item.id;
|
||||
|
||||
const selectDisplayComponent = (mode: ArticleListDisplayMode) => {
|
||||
switch (mode) {
|
||||
case "card":
|
||||
return ArticleOverviewCard;
|
||||
case "magazine":
|
||||
return ArticleMagazineCard;
|
||||
case "text-only":
|
||||
return ArticleTextOnlyCard;
|
||||
default:
|
||||
throw new Error(`Unknown display mode: ${mode}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ArticleList: ArticleListComponent = (props: ArticleListProps) => {
|
||||
const {
|
||||
data,
|
||||
displayMode = "magazine",
|
||||
horizontal = false,
|
||||
infiniteScroll = false,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
refreshing,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: ArticleOverview }) => {
|
||||
const itemWidth = horizontal ? screenWidth * 0.7 : undefined;
|
||||
const DisplayComponent = selectDisplayComponent(displayMode);
|
||||
|
||||
return (
|
||||
<View style={{ width: itemWidth }}>
|
||||
<DisplayComponent data={item} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[horizontal, displayMode]
|
||||
);
|
||||
|
||||
const handleOnEndReached = useCallback(async () => {
|
||||
if (infiniteScroll && hasNextPage && !isFetchingNextPage && fetchNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, infiniteScroll]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
{...rest}
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={horizontal ? HorizontalSeparator : VerticalSeparator}
|
||||
horizontal={horizontal}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={5}
|
||||
onEndReachedThreshold={0.5}
|
||||
removeClippedSubviews={true}
|
||||
onEndReached={handleOnEndReached}
|
||||
refreshing={refreshing}
|
||||
ListFooterComponent={infiniteScroll ? LoadingIndicator : undefined}
|
||||
ListEmptyComponent={() => <Text>Pas d’articles disponibles pour le moment.</Text>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ArticleList.HorizontalSeparator = HorizontalSeparator;
|
||||
ArticleList.VerticalSeparator = VerticalSeparator;
|
||||
ArticleList.LoadingIndicator = LoadingIndicator;
|
||||
|
||||
export { ArticleList };
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "expo-router";
|
||||
import { Card, XStack, YStack } from "tamagui";
|
||||
|
||||
import { ArticleOverview } from "@/api/schema/feed-management/article";
|
||||
import { useRelativeTime } from "@/hooks/use-relative-time";
|
||||
import { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
|
||||
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
|
||||
import { Caption, Text } from "@/ui/components/typography";
|
||||
|
||||
type ArticleMagazineCardProps = {
|
||||
data: ArticleOverview;
|
||||
};
|
||||
|
||||
export const ArticleMagazineCard = (props: ArticleMagazineCardProps) => {
|
||||
const { data } = props;
|
||||
const relativeTime = useRelativeTime(data.publishedAt);
|
||||
|
||||
return (
|
||||
<Card width="100%" backgroundColor="transparent" borderRadius="$4" padding={0}>
|
||||
<Link href={`/(authed)/(tabs)/articles/${data.id}`}>
|
||||
<XStack flexDirection="row" gap="$3" alignItems="center">
|
||||
<YStack flex={1} gap="$2">
|
||||
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text size="$3" numberOfLines={2} color="$colorHover">
|
||||
{data.excerpt}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{data.image && <ArticleCoverImage uri={data.image} width={120} height={90} />}
|
||||
</XStack>
|
||||
</Link>
|
||||
|
||||
<YStack marginTop="$3">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<SourceReferencePill data={data.source} />
|
||||
<Caption>{relativeTime}</Caption>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "expo-router";
|
||||
import { Card, XStack, YStack } from "tamagui";
|
||||
|
||||
import { ArticleOverview } from "@/api/schema/feed-management/article";
|
||||
import { useRelativeTime } from "@/hooks/use-relative-time";
|
||||
import { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
|
||||
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
|
||||
import { Caption, Text } from "@/ui/components/typography";
|
||||
|
||||
type ArticleOverviewCardProps = {
|
||||
data: ArticleOverview;
|
||||
};
|
||||
|
||||
export const ArticleOverviewCard = (props: ArticleOverviewCardProps) => {
|
||||
const { data } = props;
|
||||
const relativeTime = useRelativeTime(data.publishedAt);
|
||||
|
||||
return (
|
||||
<Card backgroundColor="transparent">
|
||||
<Link href={`/(authed)/(tabs)/articles/${data.id}`} asChild>
|
||||
<>
|
||||
{data.image && <ArticleCoverImage uri={data.image} width="100%" height={200} />}
|
||||
<YStack marginTop="$2" gap="$2">
|
||||
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text size="$3" numberOfLines={2}>
|
||||
{data.excerpt}
|
||||
</Text>
|
||||
</YStack>
|
||||
</>
|
||||
</Link>
|
||||
|
||||
<YStack marginTop="$2">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<SourceReferencePill data={data.source} />
|
||||
<Caption>{relativeTime}</Caption>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import ContentLoader, { Circle, Rect } from "react-content-loader/native";
|
||||
import { Dimensions, FlatList } from "react-native";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import { ArticleList, ArticleListDisplayMode } from "@/ui/components/content/article/ArticleList";
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const data: number[] = new Array(5).fill(0);
|
||||
|
||||
type ArticleSkeletonListProps = {
|
||||
horizontal?: boolean;
|
||||
displayMode?: ArticleListDisplayMode;
|
||||
};
|
||||
|
||||
const OverviewCardSkeleton = (props: any) => (
|
||||
<ContentLoader
|
||||
speed={1}
|
||||
interval={0.3}
|
||||
backgroundColor="#D4D5D8"
|
||||
foregroundColor="white"
|
||||
height={350}
|
||||
animate={true}
|
||||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Rect x="0" y="0" rx="8" ry="8" width="100%" height="200" />
|
||||
<Rect x="0" y="216" rx="4" ry="4" width="80%" height="10" />
|
||||
<Rect x="0" y="232" rx="4" ry="4" width="100%" height="10" />
|
||||
|
||||
<Rect x="0" y="256" rx="4" ry="4" width="100%" height="10" />
|
||||
<Rect x="0" y="272" rx="4" ry="4" width="60%" height="10" />
|
||||
|
||||
<Circle cx="10" cy="310" r="9" />
|
||||
<Rect x="30" y="305" rx="4" ry="4" width="15%" height="10" />
|
||||
<Rect x="215" y="305" rx="4" ry="4" width="20%" height="10" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const MagazineCardSkeleton = (props: any) => (
|
||||
<ContentLoader
|
||||
speed={1.5}
|
||||
backgroundColor="#D4D5D8"
|
||||
foregroundColor="white"
|
||||
height={140}
|
||||
animate={true}
|
||||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Rect x="235" y="0" rx="8" ry="8" width="120" height="90" />
|
||||
|
||||
<Rect x="0" y="0" rx="4" ry="4" width="54%" height="10" />
|
||||
<Rect x="0" y="16" rx="4" ry="4" width="56%" height="10" />
|
||||
<Rect x="0" y="40" rx="4" ry="4" width="55%" height="10" />
|
||||
<Rect x="0" y="56" rx="4" ry="4" width="55%" height="10" />
|
||||
<Rect x="0" y="72" rx="4" ry="4" width="55%" height="10" />
|
||||
|
||||
<Circle cx="10" cy="110" r="9" />
|
||||
<Rect x="30" y="105" rx="4" ry="4" width="15%" height="10" />
|
||||
<Rect x="315" y="105" rx="4" ry="4" width="40" height="10" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const TextOnlyCardSkeleton = (props: any) => (
|
||||
<ContentLoader
|
||||
speed={1.5}
|
||||
backgroundColor="#D4D5D8"
|
||||
foregroundColor="white"
|
||||
height={150}
|
||||
animate={true}
|
||||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Rect x="0" y="16" rx="4" ry="4" width="80%" height="10" />
|
||||
<Rect x="0" y="32" rx="4" ry="4" width="100%" height="10" />
|
||||
|
||||
<Rect x="0" y="56" rx="4" ry="4" width="100%" height="10" />
|
||||
<Rect x="0" y="72" rx="4" ry="4" width="60%" height="10" />
|
||||
|
||||
<Circle cx="10" cy="110" r="9" />
|
||||
<Rect x="30" y="105" rx="4" ry="4" width="15%" height="10" />
|
||||
<Rect x="215" y="105" rx="4" ry="4" width="20%" height="10" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const keyExtractor = (_: number, index: number) => index.toString();
|
||||
|
||||
const selectSkeletonComponent = (displayMode: ArticleListDisplayMode) => {
|
||||
switch (displayMode) {
|
||||
case "magazine":
|
||||
return MagazineCardSkeleton;
|
||||
case "text-only":
|
||||
return TextOnlyCardSkeleton;
|
||||
default:
|
||||
return OverviewCardSkeleton;
|
||||
}
|
||||
};
|
||||
|
||||
export const ArticleSkeletonList = (props: ArticleSkeletonListProps) => {
|
||||
const { horizontal = false, displayMode = "magazine" } = props;
|
||||
|
||||
const ItemSeparator = horizontal ? ArticleList.HorizontalSeparator : ArticleList.VerticalSeparator;
|
||||
|
||||
const renderItem = useCallback(() => {
|
||||
const itemWidth = horizontal ? screenWidth * 0.7 : screenWidth;
|
||||
const SkeletonComponent = selectSkeletonComponent(displayMode);
|
||||
|
||||
return (
|
||||
<View style={{ width: itemWidth }}>
|
||||
<SkeletonComponent />
|
||||
</View>
|
||||
);
|
||||
}, [horizontal, displayMode]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
scrollEnabled={false}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
horizontal={horizontal}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 0 }}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "expo-router";
|
||||
import { Card, XStack, YStack } from "tamagui";
|
||||
|
||||
import { ArticleOverview } from "@/api/schema/feed-management/article";
|
||||
import { useRelativeTime } from "@/hooks/use-relative-time";
|
||||
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
|
||||
import { Caption, Text } from "@/ui/components/typography";
|
||||
|
||||
type ArticleTextOnlyCardProps = {
|
||||
data: ArticleOverview;
|
||||
};
|
||||
|
||||
export const ArticleTextOnlyCard = (props: ArticleTextOnlyCardProps) => {
|
||||
const { data } = props;
|
||||
const relativeTime = useRelativeTime(data.publishedAt);
|
||||
|
||||
return (
|
||||
<Card width="100%" backgroundColor="transparent" borderRadius="$4" padding={0}>
|
||||
<Link href={`/(authed)/(tabs)/articles/${data.id}`}>
|
||||
<XStack flexDirection="row" gap="$3" alignItems="center">
|
||||
<YStack flex={1} gap="$2">
|
||||
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text size="$3" numberOfLines={2} color="$colorHover">
|
||||
{data.excerpt}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Link>
|
||||
|
||||
<YStack marginTop="$3">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<SourceReferencePill data={data.source} />
|
||||
<Caption>{relativeTime}</Caption>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ArticleCategoryPill } from "@/ui/components/content/article/ArticleCategoryPill";
|
||||
export { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
|
||||
export { ArticleList } from "@/ui/components/content/article/ArticleList";
|
||||
export { ArticleSkeletonList } from "@/ui/components/content/article/ArticleSkeleton";
|
||||
export { ArticleMagazineCard } from "@/ui/components/content/article/ArticleMagazineCard";
|
||||
export { ArticleOverviewCard } from "@/ui/components/content/article/ArticleOverviewCard";
|
||||
export { ArticleTextOnlyCard } from "@/ui/components/content/article/ArticleTextOnlyCard";
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "expo-router";
|
||||
import { Card, XStack, YStack } from "tamagui";
|
||||
|
||||
import { Bookmark } from "@/api/schema/feed-management/bookmark";
|
||||
import { useRelativeTime } from "@/hooks/use-relative-time";
|
||||
import { Caption, Text } from "@/ui/components/typography";
|
||||
|
||||
type BookmarkCardProps = {
|
||||
data: Bookmark;
|
||||
};
|
||||
|
||||
export const BookmarkCard = (props: BookmarkCardProps) => {
|
||||
const { data } = props;
|
||||
const relativeTime = useRelativeTime(data.createdAt);
|
||||
|
||||
return (
|
||||
<Card width="100%" backgroundColor="$gray7" borderRadius="$4" padding="$4">
|
||||
<XStack gap="$4" justifyContent="space-between">
|
||||
<YStack>
|
||||
<XStack flexDirection="row" gap="$3" alignItems="center">
|
||||
<Link href={`/(authed)/(tabs)/bookmarks/${data.id}`}>
|
||||
<YStack flex={1} gap="$2">
|
||||
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
|
||||
{data.name}
|
||||
</Text>
|
||||
{data.description && (
|
||||
<Text size="$3" numberOfLines={2} color="$colorHover">
|
||||
{data.description}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
</Link>
|
||||
</XStack>
|
||||
|
||||
<YStack marginTop="$3">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<Caption>{data.isPublic}</Caption>
|
||||
<Caption>{data.articlesCount} articles</Caption>
|
||||
<Caption>{relativeTime}</Caption>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import BookmarkIllustration from "@/assets/illustrations/BookmarkIllustration";
|
||||
import { CreateBookmarkSheet } from "@/ui/components/content/bookmark/CreateBookmarkSheet";
|
||||
import { Heading, Text } from "@/ui/components/typography";
|
||||
|
||||
export const BookmarkEmptyState = () => {
|
||||
return (
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2">
|
||||
<BookmarkIllustration width={250} height={250} />
|
||||
<Heading alignSelf="center">Empty Bookmarks</Heading>
|
||||
<Text textAlign="center">Create a bookmark to save your favorite articles and access them later.</Text>
|
||||
|
||||
<CreateBookmarkSheet />
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import { ActivityIndicator, FlatList, FlatListProps } from "react-native";
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import { Bookmark } from "@/api/schema/feed-management/bookmark";
|
||||
import { BookmarkCard } from "@/ui/components/content/bookmark/BookmarkCard";
|
||||
import { BookmarkEmptyState } from "@/ui/components/content/bookmark/BookmarkEmptyState";
|
||||
|
||||
const VerticalSeparator = () => <YStack height="$0.75" />;
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<>
|
||||
<YStack height="$1" />
|
||||
<ActivityIndicator />
|
||||
<YStack height="$1" />
|
||||
</>
|
||||
);
|
||||
|
||||
type BookmarkListProps = Omit<FlatListProps<Bookmark>, "renderItem"> & {
|
||||
data: Bookmark[];
|
||||
infiniteScroll?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
};
|
||||
|
||||
type BookmarkListComponent = React.FC<BookmarkListProps> & {
|
||||
VerticalSeparator: typeof VerticalSeparator;
|
||||
LoadingIndicator: typeof LoadingIndicator;
|
||||
};
|
||||
|
||||
const keyExtractor = (item: Bookmark) => item.id;
|
||||
|
||||
const renderItem = ({ item }: { item: Bookmark }) => {
|
||||
return <BookmarkCard data={item} />;
|
||||
};
|
||||
|
||||
const BookmarkList: BookmarkListComponent = (props: BookmarkListProps) => {
|
||||
const { data, infiniteScroll = false, hasNextPage, isFetchingNextPage, fetchNextPage, refreshing, ...rest } = props;
|
||||
|
||||
const handleOnEndReached = useCallback(async () => {
|
||||
if (infiniteScroll && hasNextPage && !isFetchingNextPage && fetchNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, infiniteScroll]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
{...rest}
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
onEndReached={handleOnEndReached}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={VerticalSeparator}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={5}
|
||||
onEndReachedThreshold={0.5}
|
||||
removeClippedSubviews={true}
|
||||
refreshing={refreshing}
|
||||
ListEmptyComponent={<BookmarkEmptyState />}
|
||||
ListFooterComponent={infiniteScroll && refreshing ? LoadingIndicator : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BookmarkList.VerticalSeparator = VerticalSeparator;
|
||||
BookmarkList.LoadingIndicator = LoadingIndicator;
|
||||
|
||||
export { BookmarkList };
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { joiResolver } from "@hookform/resolvers/joi";
|
||||
import { Sheet } from "@tamagui/sheet";
|
||||
import { useForm } from "react-hook-form";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { Button, YStack } from "tamagui";
|
||||
|
||||
import { useCreateBookmark } from "@/api/request/feed-management/bookmark";
|
||||
import { BookmarkPayload, BookmarkPayloadSchema } from "@/api/schema/feed-management/bookmark";
|
||||
import { ErrorResponse, safeMessage } from "@/api/shared";
|
||||
import { FormSwitch, FormTextArea, FormTextInput } from "@/ui/components/controls/forms";
|
||||
import { SubmitButton } from "@/ui/components/controls/SubmitButton";
|
||||
|
||||
export const CreateBookmarkSheet = () => {
|
||||
const { mutate, isPending } = useCreateBookmark();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { control, handleSubmit, formState } = useForm<BookmarkPayload>({
|
||||
resolver: joiResolver(BookmarkPayloadSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: BookmarkPayload) => {
|
||||
mutate(data, {
|
||||
onSuccess: () => {
|
||||
Toast.show({
|
||||
text1: "Félicitations !",
|
||||
text2: "Votre signet a été créé avec succès.",
|
||||
type: "success",
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
Toast.show({
|
||||
text1: "Erreur",
|
||||
text2: safeMessage(error),
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack alignItems="center" justifyContent="center">
|
||||
<Button onPress={() => setOpen(true)}>Ajouter un signet</Button>
|
||||
|
||||
<Sheet
|
||||
modal={true}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
snapPointsMode="percent"
|
||||
snapPoints={[65, 90]}
|
||||
dismissOnOverlayPress={true}
|
||||
dismissOnSnapToBottom={true}
|
||||
animation="medium"
|
||||
>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
<Sheet.Frame
|
||||
flex={1}
|
||||
backgroundColor="$background"
|
||||
padding="$4"
|
||||
gap="$4"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<YStack width="100%">
|
||||
<Sheet.Handle theme="accent" />
|
||||
<FormTextInput
|
||||
name="name"
|
||||
control={control}
|
||||
label="Name"
|
||||
caption="Enter a name for your bookmark."
|
||||
placeholder="My awesome bookmark"
|
||||
/>
|
||||
<FormTextArea
|
||||
name="description"
|
||||
control={control}
|
||||
caption="Describe your bookmark for easy retrieval."
|
||||
label="Description"
|
||||
placeholder="A brief description..."
|
||||
/>
|
||||
<FormSwitch
|
||||
name="isPublic"
|
||||
control={control}
|
||||
label="Public"
|
||||
description="A public bookmark is visible and accessible to other users"
|
||||
/>
|
||||
</YStack>
|
||||
<SubmitButton
|
||||
label="Créer le signet"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
isPending={isPending}
|
||||
isValid={formState.isValid}
|
||||
/>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { joiResolver } from "@hookform/resolvers/joi";
|
||||
import { Sheet } from "@tamagui/sheet";
|
||||
import { useForm } from "react-hook-form";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { Button, YStack } from "tamagui";
|
||||
|
||||
import { useUpdateBookmark } from "@/api/request/feed-management/bookmark";
|
||||
import { Bookmark, BookmarkPayload, BookmarkPayloadSchema } from "@/api/schema/feed-management/bookmark";
|
||||
import { ErrorResponse, safeMessage } from "@/api/shared";
|
||||
import { FormSwitch } from "@/ui/components/controls/forms/Switch";
|
||||
import { FormTextArea } from "@/ui/components/controls/forms/TextArea";
|
||||
import { FormTextInput } from "@/ui/components/controls/forms/TextInput";
|
||||
import { SubmitButton } from "@/ui/components/controls/SubmitButton";
|
||||
|
||||
type UpdateBookmarkSheetProps = {
|
||||
data: Bookmark;
|
||||
};
|
||||
|
||||
export const UpdateBookmarkSheet = (props: UpdateBookmarkSheetProps) => {
|
||||
const { data } = props;
|
||||
const { mutate, isPending } = useUpdateBookmark(data.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { control, handleSubmit, formState, reset } = useForm<BookmarkPayload>({
|
||||
resolver: joiResolver(BookmarkPayloadSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset({ ...data });
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = (data: BookmarkPayload) => {
|
||||
mutate(data, {
|
||||
onSuccess: () => {
|
||||
Toast.show({
|
||||
text1: "Félicitations !",
|
||||
text2: "Votre signet a été modifié avec succès.",
|
||||
type: "success",
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
Toast.show({
|
||||
text1: "Erreur",
|
||||
text2: safeMessage(error),
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack alignItems="center" justifyContent="center">
|
||||
<Button onPress={() => setOpen(true)}>Modifier</Button>
|
||||
|
||||
<Sheet
|
||||
modal={true}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
snapPointsMode="percent"
|
||||
snapPoints={[65, 90]}
|
||||
dismissOnOverlayPress={true}
|
||||
dismissOnSnapToBottom={true}
|
||||
animation="medium"
|
||||
>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
<Sheet.Frame
|
||||
flex={1}
|
||||
backgroundColor="$background"
|
||||
padding="$4"
|
||||
gap="$4"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<YStack width="100%">
|
||||
<Sheet.Handle theme="accent" />
|
||||
<FormTextInput
|
||||
name="name"
|
||||
control={control}
|
||||
label="Name"
|
||||
caption="Enter a name for your bookmark."
|
||||
placeholder="My awesome bookmark"
|
||||
/>
|
||||
<FormTextArea
|
||||
name="description"
|
||||
control={control}
|
||||
caption="Describe your bookmark for easy retrieval."
|
||||
label="Description"
|
||||
placeholder="A brief description..."
|
||||
/>
|
||||
<FormSwitch
|
||||
name="isPublic"
|
||||
control={control}
|
||||
label="Public"
|
||||
description="A public bookmark is visible and accessible to other users"
|
||||
/>
|
||||
</YStack>
|
||||
<SubmitButton
|
||||
label="Modifier le signet"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
isPending={isPending}
|
||||
isValid={formState.isValid}
|
||||
/>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { BookmarkCard } from "@/ui/components/content/bookmark/BookmarkCard";
|
||||
export { BookmarkEmptyState } from "@/ui/components/content/bookmark/BookmarkEmptyState";
|
||||
export { BookmarkList } from "@/ui/components/content/bookmark/BookmarkList";
|
||||
export { CreateBookmarkSheet } from "@/ui/components/content/bookmark/CreateBookmarkSheet";
|
||||
export { UpdateBookmarkSheet } from "@/ui/components/content/bookmark/UpdateBookmarkSheet";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { ActivityIndicator, Alert } from "react-native";
|
||||
import { Button, GetProps } from "tamagui";
|
||||
|
||||
import { useFollowSource, useUnfollowSource } from "@/api/request/feed-management/source";
|
||||
|
||||
type SourceFollowButtonProps = GetProps<typeof Button> & {
|
||||
id: string;
|
||||
name: string;
|
||||
followed: boolean;
|
||||
};
|
||||
|
||||
export const SourceFollowButton = (props: SourceFollowButtonProps) => {
|
||||
const { id, followed, name, ...rest } = props;
|
||||
const [isFollowed, setIsFollowed] = useState<boolean>(followed);
|
||||
const { mutate: follow, isPending: following } = useFollowSource(id);
|
||||
const { mutate: unfollow, isPending: unfollowing } = useUnfollowSource(id);
|
||||
const loading = following || unfollowing;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isFollowed) {
|
||||
Alert.alert(
|
||||
"Confirmation",
|
||||
`Êtes-vous sûr de vouloir ne plus suivre ${name} ?`,
|
||||
[
|
||||
{
|
||||
text: "Annuler",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Ne plus suivre",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
unfollow();
|
||||
setIsFollowed(prev => !prev);
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: false }
|
||||
);
|
||||
} else {
|
||||
follow();
|
||||
setIsFollowed(prev => !prev);
|
||||
}
|
||||
}, [isFollowed, name, unfollow, follow, setIsFollowed]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="$2"
|
||||
theme={isFollowed ? "alt1" : "surface1"}
|
||||
chromeless={isFollowed}
|
||||
disabled={loading}
|
||||
onPress={handlePress}
|
||||
minWidth={80}
|
||||
paddingHorizontal="$2"
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : isFollowed ? "Suivi" : "Suivre"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import { FlatList, FlatListProps } from "react-native";
|
||||
import { Paragraph, XStack, YStack } from "tamagui";
|
||||
|
||||
import { SourceOverview } from "@/api/schema/feed-management/source";
|
||||
import { SourceOverviewCard } from "@/ui/components/content/source/SourceOverviewCard";
|
||||
|
||||
const HorizontalSeparator = () => <XStack width="$1" />;
|
||||
const VerticalSeparator = () => <YStack height="$0.5" />;
|
||||
|
||||
type SourceOverviewListProps = Omit<FlatListProps<SourceOverview>, "renderItem"> & {
|
||||
data: SourceOverview[];
|
||||
horizontal?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
};
|
||||
|
||||
type SourceOverviewListComponent = React.FC<SourceOverviewListProps> & {
|
||||
HorizontalSeparator: typeof HorizontalSeparator;
|
||||
VerticalSeparator: typeof VerticalSeparator;
|
||||
};
|
||||
|
||||
const keyExtractor = (item: SourceOverview) => item.name;
|
||||
|
||||
const SourceList: SourceOverviewListComponent = (props: SourceOverviewListProps) => {
|
||||
const { data, horizontal = false, ...rest } = props;
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SourceOverview }) => {
|
||||
return <SourceOverviewCard data={item} horizontal={horizontal} />;
|
||||
},
|
||||
[horizontal]
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
{...rest}
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={horizontal ? HorizontalSeparator : VerticalSeparator}
|
||||
horizontal={horizontal}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={5}
|
||||
onEndReachedThreshold={0.5}
|
||||
removeClippedSubviews={true}
|
||||
ListEmptyComponent={() => <Paragraph>Pas de sources disponibles pour le moment.</Paragraph>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
SourceList.HorizontalSeparator = HorizontalSeparator;
|
||||
SourceList.VerticalSeparator = VerticalSeparator;
|
||||
|
||||
export { SourceList };
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link } from "expo-router";
|
||||
import { GetProps, styled, XStack, YStack } from "tamagui";
|
||||
|
||||
import { SourceOverview } from "@/api/schema/feed-management/source";
|
||||
import { SourceFollowButton } from "@/ui/components/content/source/SourceFollowButton";
|
||||
import { SourceProfileImage } from "@/ui/components/content/source/SourceProfileImage";
|
||||
import { Text } from "@/ui/components/typography";
|
||||
|
||||
const SourceCardFrame = styled(YStack, {
|
||||
alignItems: "center",
|
||||
gap: "$2",
|
||||
borderRadius: "$4",
|
||||
|
||||
variants: {
|
||||
horizontal: {
|
||||
true: {
|
||||
maxWidth: 100,
|
||||
flexShrink: 0,
|
||||
},
|
||||
false: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
gap: "$4",
|
||||
paddingVertical: "$2",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const);
|
||||
|
||||
type SourceCardProps = GetProps<typeof SourceCardFrame> & {
|
||||
data: SourceOverview;
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
export const SourceOverviewCard = (props: SourceCardProps) => {
|
||||
const { data, horizontal = true, ...rest } = props;
|
||||
|
||||
const nameFontSize = horizontal ? "$3" : "$4";
|
||||
|
||||
return (
|
||||
<SourceCardFrame horizontal={horizontal} {...rest}>
|
||||
<Link href={`/(authed)/(tabs)/sources/${data.name}`}>
|
||||
<SourceProfileImage name={data.name} image={data.image} size={horizontal ? 65 : 50} />
|
||||
</Link>
|
||||
|
||||
<Link href={`/(authed)/(tabs)/sources/${data.name}`} asChild>
|
||||
{horizontal ? (
|
||||
<Text
|
||||
fontSize={nameFontSize}
|
||||
fontWeight="bold"
|
||||
numberOfLines={1}
|
||||
textAlign="center"
|
||||
maxWidth="100%"
|
||||
>
|
||||
{data.displayName ?? data.name}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack flex={1} gap="$1">
|
||||
<XStack alignItems="center" gap="$1">
|
||||
<Text fontSize={nameFontSize} fontWeight="bold" numberOfLines={1}>
|
||||
{data.displayName ?? data.name}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<Text color="$accent6" fontSize="$3" numberOfLines={1}>
|
||||
{data.url}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<SourceFollowButton id={data.id} name={data.displayName ?? data.name} followed={data.followed} />
|
||||
</SourceCardFrame>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type React from "react";
|
||||
|
||||
import { GetProps, Image, styled } from "tamagui";
|
||||
|
||||
const StyledImage = styled(Image, {
|
||||
borderRadius: "$12",
|
||||
backgroundColor: "white",
|
||||
});
|
||||
|
||||
type SourceAvatarProps = GetProps<typeof StyledImage> & {
|
||||
image: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const SourceProfileImage = (props: SourceAvatarProps) => {
|
||||
const { image, name, size = 50, ...rest } = props;
|
||||
|
||||
return (
|
||||
<StyledImage
|
||||
accessibilityLabel={name}
|
||||
source={{
|
||||
uri: image,
|
||||
cache: "force-cache",
|
||||
}}
|
||||
objectFit="contain"
|
||||
width={size}
|
||||
height={size}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "expo-router";
|
||||
import { Avatar, GetProps, XStack } from "tamagui";
|
||||
|
||||
import { SourceReference } from "@/api/schema/feed-management/source";
|
||||
import { Text } from "@/ui/components/typography";
|
||||
|
||||
type SourceReferencePillProps = GetProps<typeof XStack> & {
|
||||
data: SourceReference;
|
||||
};
|
||||
|
||||
export function SourceReferencePill(props: SourceReferencePillProps) {
|
||||
const { data, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Link href={`/(authed)/(tabs)/sources/${data.name}`}>
|
||||
<XStack alignItems="center" gap="$2" justifyContent="flex-start" {...rest}>
|
||||
<Avatar circular size="$1">
|
||||
<Avatar.Image
|
||||
accessibilityLabel={data.name}
|
||||
objectFit="contain"
|
||||
backgroundColor="white"
|
||||
source={{
|
||||
uri: data.image,
|
||||
cache: "force-cache",
|
||||
}}
|
||||
/>
|
||||
<Avatar.Fallback backgroundColor="$gray10" />
|
||||
</Avatar>
|
||||
<Text size="$2" fontWeight="bold">
|
||||
{data.displayName ?? data.name}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import ContentLoader, { Circle, Rect } from "react-content-loader/native";
|
||||
import { FlatList } from "react-native";
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import { SourceList } from "@/ui/components/content/source/SourceList";
|
||||
|
||||
const data: number[] = new Array(5).fill(0);
|
||||
|
||||
type SourceSkeletonListProps = {
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
const VerticalSkeleton = (props: any) => (
|
||||
<ContentLoader
|
||||
speed={1.5}
|
||||
backgroundColor="#D4D5D8"
|
||||
foregroundColor="white"
|
||||
height={70}
|
||||
animate={true}
|
||||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Circle cx="25" cy="30" r="25" />
|
||||
<Rect x="70" y="10" rx="4" ry="4" width="25%" height="10" />
|
||||
<Rect x="70" y="40" rx="4" ry="4" width="45%" height="10" />
|
||||
<Rect x="280" y="15" rx="4" ry="4" width="20%" height="30" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const HorizontalSkeleton = (props: any) => (
|
||||
<ContentLoader
|
||||
speed={1.5}
|
||||
backgroundColor="#D4D5D8"
|
||||
foregroundColor="white"
|
||||
height={180}
|
||||
animate={true}
|
||||
width={110}
|
||||
{...props}
|
||||
>
|
||||
<Circle cx="60" cy="40" r="33" />
|
||||
<Rect x="10" y="85" rx="4" ry="4" width="100" height="10" />
|
||||
<Rect x="25" y="105" rx="8" ry="8" width="70" height="25" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const keyExtractor = (_: number, index: number) => index.toString();
|
||||
|
||||
const selectSkeletonComponent = (horizontal: boolean) => {
|
||||
return horizontal ? (
|
||||
<HorizontalSkeleton />
|
||||
) : (
|
||||
<YStack width="100%">
|
||||
<VerticalSkeleton />
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const SourceSkeletonList = (props: SourceSkeletonListProps) => {
|
||||
const { horizontal = false } = props;
|
||||
|
||||
const ItemSeparator = horizontal ? SourceList.HorizontalSeparator : SourceList.VerticalSeparator;
|
||||
|
||||
const renderItem = useCallback(() => {
|
||||
return selectSkeletonComponent(horizontal);
|
||||
}, [horizontal]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
scrollEnabled={false}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
horizontal={horizontal}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 0 }}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SourceList } from "@/ui/components/content/source/SourceList";
|
||||
export { SourceFollowButton } from "@/ui/components/content/source/SourceFollowButton";
|
||||
export { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
|
||||
export { SourceSkeletonList } from "@/ui/components/content/source/SourceSkeleton";
|
||||
export { SourceProfileImage } from "@/ui/components/content/source/SourceProfileImage";
|
||||
export { SourceOverviewCard } from "@/ui/components/content/source/SourceOverviewCard";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ArrowLeft } from "@tamagui/lucide-icons";
|
||||
import { Button, ButtonProps } from "tamagui";
|
||||
|
||||
type BackButtonProps = {
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export const BackButton = (props: BackButtonProps & ButtonProps) => {
|
||||
const { onPress, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
chromeless
|
||||
alignSelf="flex-start"
|
||||
size="$4"
|
||||
width="$4"
|
||||
height="$4"
|
||||
borderRadius="$12"
|
||||
// backgroundColor="$gray6"
|
||||
icon={<ArrowLeft size="$1" />}
|
||||
onPress={onPress}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button, ButtonProps } from "tamagui";
|
||||
|
||||
type IconButtonProps = {
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export const IconButton = (props: IconButtonProps & ButtonProps) => {
|
||||
const { onPress, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
chromeless
|
||||
alignSelf="flex-start"
|
||||
size="$4"
|
||||
width="$4"
|
||||
height="$4"
|
||||
borderRadius="$12"
|
||||
onPress={onPress}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { Button, GetProps, styled } from "tamagui";
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
fontWeight: "bold",
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
type SubmitButtonProps = GetProps<typeof StyledButton> & {
|
||||
label: string;
|
||||
isValid: boolean;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const SubmitButton = (props: SubmitButtonProps) => {
|
||||
const { isValid, isPending, label, ...rest } = props;
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
disabled={isPending}
|
||||
theme={!isValid || isPending ? "disabled" : "accent"}
|
||||
fontWeight="bold"
|
||||
{...rest}
|
||||
>
|
||||
{isPending ? <ActivityIndicator /> : label}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Mail } from "@tamagui/lucide-icons";
|
||||
|
||||
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
|
||||
import { withController } from "@/ui/components/controls/forms/withController";
|
||||
|
||||
export const EmailInput = (props: InputProps) => {
|
||||
const { label = "Email", caption, error, onChangeText, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
caption={caption}
|
||||
error={error}
|
||||
leadingAdornment={Mail}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="votre@email.com..."
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormEmailInput = withController<InputProps>(EmailInput);
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { IconProps } from "@tamagui/helpers-icon";
|
||||
import { ColorTokens, GetProps, Input as TamaguiInput, Label, SizeTokens, styled, XStack, YStack } from "tamagui";
|
||||
|
||||
import { Caption } from "@/ui/components/typography";
|
||||
|
||||
const StyledInput = styled(TamaguiInput, {
|
||||
size: "$large",
|
||||
flex: 1,
|
||||
borderWidth: 0,
|
||||
placeholderTextColor: "$gray8",
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
|
||||
export type InputProps = GetProps<typeof StyledInput> & {
|
||||
label?: string;
|
||||
caption?: string;
|
||||
error?: string;
|
||||
leadingAdornment?: React.ComponentType<IconProps & { size?: SizeTokens; color?: ColorTokens }>;
|
||||
trailingAdornment?: React.ReactNode;
|
||||
onChangeText?: (text: string) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const Input = (props: InputProps) => {
|
||||
const { label, caption, error, leadingAdornment, trailingAdornment, onChangeText, id, ...rest } = props;
|
||||
|
||||
const isInvalid = !!error;
|
||||
const leadingAdornmentComponent = useMemo(() => {
|
||||
return leadingAdornment ? (
|
||||
<XStack paddingLeft="$3" style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
{React.createElement(leadingAdornment, {
|
||||
size: "$1",
|
||||
color: "$gray9",
|
||||
})}
|
||||
</XStack>
|
||||
) : undefined;
|
||||
}, [leadingAdornment]);
|
||||
|
||||
return (
|
||||
<YStack gap="$1">
|
||||
<YStack>
|
||||
{label && (
|
||||
<Label htmlFor={id} fontWeight="bold" color={isInvalid ? "$red9" : undefined}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<XStack
|
||||
backgroundColor="$gray4"
|
||||
alignItems="center"
|
||||
borderRadius="$4"
|
||||
borderWidth="$0.5"
|
||||
borderColor={isInvalid ? "$red9" : "transparent"}
|
||||
focusStyle={{
|
||||
borderColor: "$accent8",
|
||||
}}
|
||||
pressStyle={{
|
||||
borderColor: "$accent8",
|
||||
}}
|
||||
>
|
||||
{leadingAdornmentComponent}
|
||||
<StyledInput id={id} onChangeText={onChangeText} {...rest} />
|
||||
{trailingAdornment}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{caption && !isInvalid && <Caption>{caption}</Caption>}
|
||||
{isInvalid && error && <Caption color="$red9">{error}</Caption>}
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Eye, EyeOff, Lock } from "@tamagui/lucide-icons";
|
||||
import { XStack } from "tamagui";
|
||||
|
||||
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
|
||||
import { withController } from "@/ui/components/controls/forms/withController";
|
||||
|
||||
export const PasswordInput = (props: InputProps) => {
|
||||
const { label = "Mot de passe", onChangeText, caption, error, ...rest } = props;
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
onChangeText={onChangeText}
|
||||
caption={caption}
|
||||
error={error}
|
||||
leadingAdornment={Lock}
|
||||
secureTextEntry={!showPassword}
|
||||
paddingRight="$6"
|
||||
placeholder="Mot de passe"
|
||||
trailingAdornment={
|
||||
<XStack
|
||||
paddingRight="$3"
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
{showPassword ? <Eye size="$1" color="$gray9" /> : <EyeOff size="$1" color="$gray9" />}
|
||||
</XStack>
|
||||
}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormPasswordInput = withController<InputProps>(PasswordInput);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GetProps, Label, Switch as TamaguiSwitch, XStack, YStack } from "tamagui";
|
||||
|
||||
import { withController } from "@/ui/components/controls/forms/withController";
|
||||
import { Caption } from "@/ui/components/typography";
|
||||
|
||||
type SwitchProps = GetProps<typeof TamaguiSwitch> & {
|
||||
label: string;
|
||||
description?: string;
|
||||
isChecked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const Switch = (props: SwitchProps) => {
|
||||
const { label, description, isChecked, onCheckedChange, id, ...rest } = props;
|
||||
|
||||
return (
|
||||
<XStack alignItems="center" gap="$10" justifyContent="space-between" width="100%">
|
||||
<YStack flex={1} gap="$1">
|
||||
<Label fontWeight="bold" htmlFor={id}>
|
||||
{label}
|
||||
</Label>
|
||||
{description && <Caption>{description}</Caption>}
|
||||
</YStack>
|
||||
<TamaguiSwitch id={id} checked={isChecked} onCheckedChange={onCheckedChange} size="$3" {...rest}>
|
||||
<TamaguiSwitch.Thumb animation="bouncy" />
|
||||
</TamaguiSwitch>
|
||||
</XStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormSwitch = withController<SwitchProps>(Switch);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { GetProps, Label, styled, TextArea as TamaguiTextArea, XStack, YStack } from "tamagui";
|
||||
|
||||
import { withController } from "@/ui/components/controls/forms/withController";
|
||||
import { Caption } from "@/ui/components/typography";
|
||||
|
||||
const StyledTextArea = styled(TamaguiTextArea, {
|
||||
size: "$4",
|
||||
flex: 1,
|
||||
minHeight: 100,
|
||||
borderWidth: 0,
|
||||
placeholderTextColor: "$gray8",
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
|
||||
type TextAreaProps = GetProps<typeof StyledTextArea> & {
|
||||
label?: string;
|
||||
caption?: string;
|
||||
error?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const TextArea = (props: TextAreaProps) => {
|
||||
const { label, caption, error, onChangeText, id, ...rest } = props;
|
||||
|
||||
const isInvalid = !!error;
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
{label && (
|
||||
<Label htmlFor={id} fontWeight="bold" color={isInvalid ? "$red9" : undefined}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<XStack
|
||||
alignItems="flex-start"
|
||||
backgroundColor="$gray4"
|
||||
borderRadius="$4"
|
||||
borderWidth="$0.5"
|
||||
borderColor={isInvalid ? "$red9" : "transparent"}
|
||||
focusStyle={{
|
||||
borderColor: "$accent8",
|
||||
}}
|
||||
pressStyle={{
|
||||
borderColor: "$accent8",
|
||||
}}
|
||||
>
|
||||
<StyledTextArea id={id} onChangeText={onChangeText} {...rest} />
|
||||
</XStack>
|
||||
|
||||
{caption && !isInvalid && <Caption>{caption}</Caption>}
|
||||
{isInvalid && error && <Caption color="$red9">{error}</Caption>}
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormTextArea = withController<TextAreaProps>(TextArea);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
|
||||
import { withController } from "@/ui/components/controls/forms/withController";
|
||||
|
||||
export const TextInput = (props: InputProps) => {
|
||||
const { label, caption, error, leadingAdornment, onChangeText, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
caption={caption}
|
||||
error={error}
|
||||
leadingAdornment={leadingAdornment}
|
||||
onChangeText={onChangeText}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormTextInput = withController<InputProps>(TextInput);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EmailInput, FormEmailInput } from "@/ui/components/controls/forms/EmailInput";
|
||||
export { TextInput, FormTextInput } from "@/ui/components/controls/forms/TextInput";
|
||||
export { PasswordInput, FormPasswordInput } from "@/ui/components/controls/forms/PasswordInput";
|
||||
export { Switch, FormSwitch } from "@/ui/components/controls/forms/Switch";
|
||||
export { TextArea, FormTextArea } from "@/ui/components/controls/forms/TextArea";
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
|
||||
import { Controller, ControllerProps } from "react-hook-form";
|
||||
|
||||
type WithControllerProps = {
|
||||
value?: any;
|
||||
onChangeText?: (val: any) => void;
|
||||
isChecked?: boolean;
|
||||
onCheckedChange?: (val: boolean) => void;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ControllerWrapperProps<T> = {
|
||||
name: string;
|
||||
control: ControllerProps<any, any, any>["control"];
|
||||
} & Omit<T, keyof WithControllerProps>;
|
||||
|
||||
export const withController = <T extends WithControllerProps>(Component: React.ComponentType<T>) => {
|
||||
const ControllerWrapper = (props: ControllerWrapperProps<T>) => {
|
||||
const { name, control, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
const hasSwitchProps = "isChecked" in rest || "onCheckedChange" in rest;
|
||||
|
||||
const controllerProps: WithControllerProps = {
|
||||
error: error?.message,
|
||||
};
|
||||
|
||||
if (hasSwitchProps) {
|
||||
controllerProps.isChecked = value;
|
||||
controllerProps.onCheckedChange = onChange;
|
||||
} else {
|
||||
controllerProps.value = value;
|
||||
controllerProps.onChangeText = onChange;
|
||||
}
|
||||
|
||||
return <Component {...(rest as unknown as T)} {...(controllerProps as T)} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ControllerWrapper.displayName = `withController(${Component.displayName || Component.name || "Component"})`;
|
||||
|
||||
return ControllerWrapper;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
|
||||
import { styled, View, XStack } from "tamagui";
|
||||
|
||||
import { Text } from "@/ui/components/typography";
|
||||
|
||||
const ActionContainer = styled(XStack, {
|
||||
alignItems: "center",
|
||||
minWidth: "$5",
|
||||
gap: "$1",
|
||||
});
|
||||
|
||||
interface ScreenHeadingProps {
|
||||
leadingAction?: React.ReactNode;
|
||||
title?: string;
|
||||
trailingActions?: React.ReactNode | React.ReactNode[];
|
||||
paddingHorizontal?: number | string;
|
||||
marginBottom?: number | string;
|
||||
}
|
||||
|
||||
export const ScreenHeading = (props: ScreenHeadingProps) => {
|
||||
const { leadingAction, title, trailingActions, paddingHorizontal = "$4", marginBottom = "$2" } = props;
|
||||
const trailingActionsArray = Array.isArray(trailingActions)
|
||||
? trailingActions
|
||||
: trailingActions
|
||||
? [trailingActions]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
height="$6"
|
||||
backgroundColor="$background"
|
||||
paddingHorizontal={paddingHorizontal}
|
||||
marginBottom={marginBottom}
|
||||
>
|
||||
<ActionContainer>{leadingAction}</ActionContainer>
|
||||
<XStack flex={1} justifyContent="center">
|
||||
{title ? (
|
||||
<Text fontWeight="600" fontSize="$6">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
</XStack>
|
||||
<ActionContainer>
|
||||
{trailingActionsArray.map((action, index) => (
|
||||
<React.Fragment key={index}>{action}</React.Fragment>
|
||||
))}
|
||||
</ActionContainer>
|
||||
</XStack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type React from "react";
|
||||
|
||||
import { ArrowRight } from "@tamagui/lucide-icons";
|
||||
import { Href, Link } from "expo-router";
|
||||
import { GetProps, Paragraph, styled, XStack } from "tamagui";
|
||||
|
||||
import { Text } from "@/ui/components/typography";
|
||||
|
||||
const SectionContainer = styled(XStack, {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
paddingVertical: "$2",
|
||||
});
|
||||
|
||||
type ScreenSectionProps = GetProps<typeof SectionContainer> & {
|
||||
title: string;
|
||||
forwardLink?: Href;
|
||||
};
|
||||
|
||||
type ScreenSectionLinkProps = {
|
||||
href: Href;
|
||||
};
|
||||
|
||||
const ScreenSectionLink = ({ href }: ScreenSectionLinkProps) => (
|
||||
<Link href={href} push asChild>
|
||||
<XStack gap="2" alignItems="center">
|
||||
<Paragraph color="$accent5" fontWeight={500}>
|
||||
Voir tout
|
||||
</Paragraph>
|
||||
<ArrowRight color="$accent5" />
|
||||
</XStack>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const ScreenSection = (props: ScreenSectionProps) => {
|
||||
const { title, forwardLink, ...rest } = props;
|
||||
|
||||
return (
|
||||
<SectionContainer {...rest}>
|
||||
<Text fontSize="$6" fontWeight="bold" color="$color" numberOfLines={1} flexShrink={1} marginRight="$2">
|
||||
{title}
|
||||
</Text>
|
||||
{forwardLink && <ScreenSectionLink href={forwardLink} />}
|
||||
</SectionContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { styled, YStack } from "tamagui";
|
||||
|
||||
import { ScreenHeading } from "@/ui/components/layout/ScreenHeading";
|
||||
import { ScreenSection } from "@/ui/components/layout/ScreenSection";
|
||||
|
||||
type ScreenViewProps = React.ComponentProps<typeof YStack> & {
|
||||
showStatusBar?: boolean;
|
||||
statusBarStyle?: "auto" | "inverted" | "light" | "dark";
|
||||
statusBarBackgroundColor?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type ScreenViewComponent = React.FC<React.PropsWithChildren<ScreenViewProps>> & {
|
||||
Heading: typeof ScreenHeading;
|
||||
Section: typeof ScreenSection;
|
||||
};
|
||||
|
||||
const ScreenContent = styled(YStack, {
|
||||
gap: "$4",
|
||||
paddingHorizontal: "$4",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
const ScreenView: ScreenViewComponent = (props: React.PropsWithChildren<ScreenViewProps>) => {
|
||||
const {
|
||||
showStatusBar = true,
|
||||
statusBarStyle = "auto",
|
||||
statusBarBackgroundColor = "transparent",
|
||||
padding,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
let headingElement: React.ReactNode | null = null;
|
||||
const otherChildren: React.ReactNode[] = [];
|
||||
|
||||
// Iterate through children to find the Heading and separate others
|
||||
React.Children.forEach(children, child => {
|
||||
if (React.isValidElement(child)) {
|
||||
if (child.type === ScreenView.Heading) {
|
||||
headingElement = child;
|
||||
} else {
|
||||
otherChildren.push(child);
|
||||
}
|
||||
} else {
|
||||
otherChildren.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{showStatusBar ? <StatusBar style={statusBarStyle} backgroundColor={statusBarBackgroundColor} /> : null}
|
||||
|
||||
<YStack flex={1} paddingTop={insets.top} backgroundColor="$background">
|
||||
{headingElement}
|
||||
|
||||
<ScreenContent
|
||||
flex={1}
|
||||
paddingBottom={insets.bottom}
|
||||
paddingHorizontal={padding ?? rest.paddingHorizontal ?? "$4"}
|
||||
{...rest}
|
||||
>
|
||||
{otherChildren}
|
||||
</ScreenContent>
|
||||
</YStack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ScreenView.Heading = ScreenHeading;
|
||||
ScreenView.Section = ScreenSection;
|
||||
|
||||
export { ScreenView };
|
||||
@@ -0,0 +1 @@
|
||||
export { ScreenView } from "@/ui/components/layout/ScreenView";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Paragraph, ParagraphProps } from "tamagui";
|
||||
|
||||
export const Caption = (props: React.PropsWithChildren<ParagraphProps>) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Paragraph fontSize="$2" lineHeight="$1" color="$gray10" {...rest}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from "react";
|
||||
|
||||
import { H2, ParagraphProps } from "tamagui";
|
||||
|
||||
export const Display = (props: React.PropsWithChildren<ParagraphProps>) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<H2 fontWeight="bold" lineHeight="$8" {...rest}>
|
||||
{children}
|
||||
</H2>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from "react";
|
||||
|
||||
import { H4, ParagraphProps } from "tamagui";
|
||||
|
||||
export const Heading = (props: React.PropsWithChildren<ParagraphProps>) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<H4 fontWeight="bold" alignSelf="flex-start" {...rest}>
|
||||
{children}
|
||||
</H4>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Paragraph, ParagraphProps } from "tamagui";
|
||||
|
||||
export const Text = (props: React.PropsWithChildren<ParagraphProps>) => {
|
||||
const { children, ...rest } = props;
|
||||
return <Paragraph {...rest}>{children}</Paragraph>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Display } from "@/ui/components/typography/Display";
|
||||
export { Heading } from "@/ui/components/typography/Heading";
|
||||
export { Caption } from "@/ui/components/typography/Caption";
|
||||
export { Text } from "@/ui/components/typography/Text";
|
||||
Reference in New Issue
Block a user